Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd94bf7e66 | ||
|
|
baa6283a15 | ||
|
|
bfb3115a64 | ||
|
|
db8b14ba4e | ||
|
|
46244fe066 | ||
|
|
3ed70b6999 | ||
|
|
b05b1acd7a | ||
|
|
c777853c97 | ||
|
|
9225318e1a | ||
|
|
061b7fad8b | ||
|
|
be7957c078 | ||
|
|
91667e1bfd | ||
|
|
1fecccaafd | ||
|
|
d100c8030f | ||
|
|
f1906517a9 | ||
|
|
00fde25c50 | ||
|
|
4c5af99efa | ||
|
|
8bf12e7721 | ||
|
|
5a0ca8ef1c | ||
|
|
714602f3da | ||
|
|
baac3a512b | ||
|
|
559284f0b2 | ||
|
|
e5fc925bae | ||
|
|
318a319608 | ||
|
|
0b55d5a8fa | ||
|
|
c3878cdc6d |
@@ -55,6 +55,10 @@ class SessionChannel < ApplicationCable::Channel
|
|||||||
Sessions::Pause.new(session).call
|
Sessions::Pause.new(session).call
|
||||||
when "resume_stream"
|
when "resume_stream"
|
||||||
Sessions::Resume.new(session).call
|
Sessions::Resume.new(session).call
|
||||||
|
when "mute_audio"
|
||||||
|
Sessions::SetAudioMute.new(session, muted: true).call
|
||||||
|
when "unmute_audio"
|
||||||
|
Sessions::SetAudioMute.new(session, muted: false).call
|
||||||
when "set_min_quality"
|
when "set_min_quality"
|
||||||
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).call
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class StreamNodesController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
Streams::NodeRegistry.ensure_home_from_env!
|
||||||
|
@nodes = StreamNode.order(:role, :slug)
|
||||||
|
@dns_provider = ENV.fetch("STREAM_DNS_PROVIDER", "lab")
|
||||||
|
@cloud_provider = ENV.fetch("STREAM_CLOUD_PROVIDER", "local_lab")
|
||||||
|
@lab_hosts = lab_dns_snippet
|
||||||
|
@hetzner_configured = ENV["HCLOUD_TOKEN"].present?
|
||||||
|
@autoscale_metrics = Streams::Autoscaler.metrics
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
kind = params[:kind].to_s
|
||||||
|
node =
|
||||||
|
if kind == "cloud"
|
||||||
|
Streams::NodeProvisioner.new.provision_cloud!
|
||||||
|
else
|
||||||
|
Streams::NodeProvisioner.new.provision_lab!
|
||||||
|
end
|
||||||
|
redirect_to admin_stream_nodes_path,
|
||||||
|
notice: t("admin.flash.stream_node_created", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::Error, Streams::CloudProviders::Error, Streams::DnsProviders::Error,
|
||||||
|
KeyError => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def drain
|
||||||
|
node = StreamNode.find(params[:id])
|
||||||
|
Streams::NodeProvisioner.new.drain!(node)
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_draining", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::Error => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
node = StreamNode.find(params[:id])
|
||||||
|
Streams::NodeProvisioner.new.decommission!(node)
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.stream_node_destroyed", slug: node.slug)
|
||||||
|
rescue Streams::NodeProvisioner::BusyError, Streams::NodeProvisioner::Error,
|
||||||
|
Streams::CloudProviders::Error, Streams::DnsProviders::Error => e
|
||||||
|
redirect_to admin_stream_nodes_path, alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def kill_switch
|
||||||
|
Streams::Autoscaler.engage_kill_switch!
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_on")
|
||||||
|
end
|
||||||
|
|
||||||
|
def clear_kill_switch
|
||||||
|
Streams::Autoscaler.clear_kill_switch!
|
||||||
|
redirect_to admin_stream_nodes_path, notice: t("admin.flash.autoscale_kill_off")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def lab_dns_snippet
|
||||||
|
return "" unless @dns_provider == "lab"
|
||||||
|
|
||||||
|
Streams::DnsProviders::Lab.new.hosts_file_snippet
|
||||||
|
rescue Redis::BaseError
|
||||||
|
""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -34,6 +34,12 @@ module Api
|
|||||||
render json: session_json(@session)
|
render json: session_json(@session)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def audio_mute
|
||||||
|
muted = params.key?(:muted) ? params[:muted] : true
|
||||||
|
Sessions::SetAudioMute.new(@session, muted: muted).call
|
||||||
|
render json: session_json(@session)
|
||||||
|
end
|
||||||
|
|
||||||
def score
|
def score
|
||||||
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
||||||
render json: session_json(@session, detail: true)
|
render json: session_json(@session, detail: true)
|
||||||
@@ -193,6 +199,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,
|
||||||
@@ -203,7 +210,8 @@ module Api
|
|||||||
min_quality_preset: session.min_quality_preset,
|
min_quality_preset: session.min_quality_preset,
|
||||||
target_bitrate: session.target_bitrate,
|
target_bitrate: session.target_bitrate,
|
||||||
started_at: session.started_at,
|
started_at: session.started_at,
|
||||||
disconnection_count: session.disconnection_count
|
disconnection_count: session.disconnection_count,
|
||||||
|
audio_muted: session.audio_muted
|
||||||
}
|
}
|
||||||
if detail
|
if detail
|
||||||
data[:score] = session.score_state&.as_cable_payload
|
data[:score] = session.score_state&.as_cable_payload
|
||||||
|
|||||||
@@ -4,11 +4,20 @@ module MediamtxPlayback
|
|||||||
private
|
private
|
||||||
|
|
||||||
def mediamtx_paths_index
|
def mediamtx_paths_index
|
||||||
@mediamtx_paths_index ||= Mediamtx::Client.new.list_paths.index_by { |i| i["name"] }
|
mediamtx_paths_index_for_url(MatchLiveTv.mediamtx_api_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_paths_index_for(session)
|
||||||
|
mediamtx_paths_index_for_url(session.mediamtx_api_base_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_paths_index_for_url(api_url)
|
||||||
|
@mediamtx_paths_by_origin ||= {}
|
||||||
|
@mediamtx_paths_by_origin[api_url] ||= Mediamtx::Client.new(base_url: api_url).list_paths.index_by { |i| i["name"] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
|
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
|
||||||
mediamtx_paths_index[path_name]
|
mediamtx_paths_index_for(session)[path_name]
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_playback_path_name(session)
|
def mediamtx_playback_path_name(session)
|
||||||
@@ -28,7 +37,7 @@ module MediamtxPlayback
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_publisher_online?(session)
|
def mediamtx_publisher_online?(session)
|
||||||
info = mediamtx_paths_index[session.mediamtx_path_name]
|
info = mediamtx_paths_index_for(session)[session.mediamtx_path_name]
|
||||||
info && info["online"]
|
info && info["online"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ class HlsProxyController < ActionController::Base
|
|||||||
def proxy_mediamtx_path(upstream_path)
|
def proxy_mediamtx_path(upstream_path)
|
||||||
return head :not_found if upstream_path.blank?
|
return head :not_found if upstream_path.blank?
|
||||||
|
|
||||||
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}"
|
origin = hls_origin_for(upstream_path)
|
||||||
|
upstream = "#{origin}/#{upstream_path}"
|
||||||
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
|
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
|
||||||
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
|
cookie = request.headers["Cookie"].presence || "cookieCheck=1"
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ class HlsProxyController < ActionController::Base
|
|||||||
if response.status.in?([301, 302, 307, 308])
|
if response.status.in?([301, 302, 307, 308])
|
||||||
location = response.headers["location"].to_s
|
location = response.headers["location"].to_s
|
||||||
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
|
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
|
||||||
upstream = resolve_upstream_url(location)
|
upstream = resolve_upstream_url(location, origin)
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -43,10 +44,21 @@ class HlsProxyController < ActionController::Base
|
|||||||
head :bad_gateway
|
head :bad_gateway
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_upstream_url(location)
|
def hls_origin_for(upstream_path)
|
||||||
|
session_id = upstream_path[/\Alive\/match_([0-9a-f-]{36})/i, 1]
|
||||||
|
if session_id
|
||||||
|
session = StreamSession.find_by(id: session_id)
|
||||||
|
node_hls = session&.stream_node&.internal_hls_url.presence
|
||||||
|
return node_hls.sub(%r{/$}, "") if node_hls.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_upstream_url(location, origin)
|
||||||
return location if location.start_with?("http://", "https://")
|
return location if location.start_with?("http://", "https://")
|
||||||
|
|
||||||
base = MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
|
base = origin.to_s.sub(%r{/$}, "")
|
||||||
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
|
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ module Public
|
|||||||
layout "regia"
|
layout "regia"
|
||||||
|
|
||||||
protect_from_forgery with: :null_session
|
protect_from_forgery with: :null_session
|
||||||
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop min_quality]
|
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute min_quality]
|
||||||
|
|
||||||
before_action :set_session_from_token
|
before_action :set_session_from_token
|
||||||
|
|
||||||
@@ -61,6 +61,12 @@ module Public
|
|||||||
render json: status_payload
|
render json: status_payload
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def audio_mute
|
||||||
|
muted = params.key?(:muted) ? params[:muted] : !@session.audio_muted
|
||||||
|
Sessions::SetAudioMute.new(@session, muted: muted).call
|
||||||
|
render json: status_payload
|
||||||
|
end
|
||||||
|
|
||||||
def min_quality
|
def min_quality
|
||||||
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
|
||||||
render json: status_payload
|
render json: status_payload
|
||||||
@@ -90,6 +96,7 @@ module Public
|
|||||||
{
|
{
|
||||||
status: @session.status,
|
status: @session.status,
|
||||||
paused: @session.paused?,
|
paused: @session.paused?,
|
||||||
|
audio_muted: @session.audio_muted,
|
||||||
stream_closed: closed,
|
stream_closed: closed,
|
||||||
live: !closed && (@session.live? || @session.paused? || publisher_online),
|
live: !closed && (@session.live? || @session.paused? || publisher_online),
|
||||||
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
|
on_air: playable || (!closed && @session.status.in?(%w[connecting live reconnecting paused])),
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ module Public
|
|||||||
|
|
||||||
def invite
|
def invite
|
||||||
require_club_owner_for_team!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
@entitlements = @team.entitlements
|
redirect_to public_team_details_path(@team, anchor: "invita-trasmissione")
|
||||||
end
|
end
|
||||||
|
|
||||||
def assign_self_staff
|
def assign_self_staff
|
||||||
@@ -87,18 +87,32 @@ module Public
|
|||||||
email = params[:email]&.downcase&.strip
|
email = params[:email]&.downcase&.strip
|
||||||
Teams::StaffEmailValidator.assert_available!(team: @team, email: email, staff_kind: staff_kind)
|
Teams::StaffEmailValidator.assert_available!(team: @team, email: email, staff_kind: staff_kind)
|
||||||
token = TeamInvitation.generate_token
|
token = TeamInvitation.generate_token
|
||||||
@team.team_invitations.create!(
|
invitation = @team.team_invitations.create!(
|
||||||
email: email,
|
email: email,
|
||||||
token_digest: Digest::SHA256.hexdigest(token),
|
token_digest: Digest::SHA256.hexdigest(token),
|
||||||
role: "member",
|
role: "member",
|
||||||
staff_kind: staff_kind,
|
staff_kind: staff_kind,
|
||||||
expires_at: 7.days.from_now
|
expires_at: 7.days.from_now
|
||||||
)
|
)
|
||||||
@invite_url = join_public_invitation_url(token: token)
|
invite_url = public_invitation_url(token: token)
|
||||||
flash.now[:notice] = t("flash.teams.invite_link_generated")
|
flash[:invite_url] = invite_url
|
||||||
render :invite
|
|
||||||
|
begin
|
||||||
|
Teams::InvitationMailer.transmission_invite(
|
||||||
|
team: @team,
|
||||||
|
invitation: invitation,
|
||||||
|
invite_url: invite_url,
|
||||||
|
invited_by: current_user
|
||||||
|
).deliver_now
|
||||||
|
flash[:notice] = t("flash.teams.invite_email_sent", email: email)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[invite_email] #{e.class}: #{e.message}")
|
||||||
|
flash[:alert] = t("flash.teams.invite_email_failed", email: email)
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to public_team_details_path(@team, anchor: "invito-generato")
|
||||||
rescue Teams::EntitlementError, Teams::StaffAssignmentError => e
|
rescue Teams::EntitlementError, Teams::StaffAssignmentError => e
|
||||||
redirect_to public_team_invite_path(@team), alert: e.message
|
redirect_to public_team_details_path(@team, anchor: "invita-trasmissione"), alert: e.message
|
||||||
end
|
end
|
||||||
|
|
||||||
def remove_member
|
def remove_member
|
||||||
|
|||||||
@@ -57,6 +57,10 @@ module Public
|
|||||||
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
|
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def plan_cta_name(plan)
|
||||||
|
I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
|
||||||
|
end
|
||||||
|
|
||||||
def billing_plan_change_info_lines(subscription: nil)
|
def billing_plan_change_info_lines(subscription: nil)
|
||||||
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
|
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ module Public
|
|||||||
partialsPrefix: t("regia.board.partials_prefix"),
|
partialsPrefix: t("regia.board.partials_prefix"),
|
||||||
resumed: t("regia.js.resumed"),
|
resumed: t("regia.js.resumed"),
|
||||||
pausedCover: t("regia.js.paused_cover"),
|
pausedCover: t("regia.js.paused_cover"),
|
||||||
|
muted: t("regia.js.muted"),
|
||||||
|
unmuted: t("regia.js.unmuted"),
|
||||||
closed: t("regia.js.closed"),
|
closed: t("regia.js.closed"),
|
||||||
resumeError: t("regia.js.resume_error"),
|
resumeError: t("regia.js.resume_error"),
|
||||||
pauseError: t("regia.js.pause_error"),
|
pauseError: t("regia.js.pause_error"),
|
||||||
|
muteError: t("regia.js.mute_error"),
|
||||||
closeError: t("regia.js.close_error"),
|
closeError: t("regia.js.close_error"),
|
||||||
closeConfirm: t("regia.js.close_confirm"),
|
closeConfirm: t("regia.js.close_confirm"),
|
||||||
linkUnavailable: t("regia.js.link_unavailable"),
|
linkUnavailable: t("regia.js.link_unavailable"),
|
||||||
@@ -64,6 +67,8 @@ module Public
|
|||||||
previewOffHint: t("regia.preview_off_hint"),
|
previewOffHint: t("regia.preview_off_hint"),
|
||||||
resumeLabel: t("regia.resume"),
|
resumeLabel: t("regia.resume"),
|
||||||
pauseLabel: t("regia.pause"),
|
pauseLabel: t("regia.pause"),
|
||||||
|
muteLabel: t("regia.mute"),
|
||||||
|
unmuteLabel: t("regia.unmute"),
|
||||||
endedBadge: t("regia.status.ended"),
|
endedBadge: t("regia.status.ended"),
|
||||||
pausedBadge: t("regia.status.paused"),
|
pausedBadge: t("regia.status.paused"),
|
||||||
liveBadge: t("regia.status.live"),
|
liveBadge: t("regia.status.live"),
|
||||||
|
|||||||
@@ -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 sul worker del nodo (coda youtube_relay_<slug>).
|
||||||
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,23 @@
|
|||||||
|
# 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, queue: Streams::YoutubeRelay.queue_for(session))
|
||||||
|
.perform_later(session_id, attempts + 1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
module Teams
|
||||||
|
class InvitationMailer < ApplicationMailer
|
||||||
|
default from: -> { MatchLiveTv.mail_from }
|
||||||
|
|
||||||
|
def transmission_invite(team:, invitation:, invite_url:, invited_by:)
|
||||||
|
@team = team
|
||||||
|
@club = team.club
|
||||||
|
@invitation = invitation
|
||||||
|
@invite_url = invite_url
|
||||||
|
@invited_by = invited_by
|
||||||
|
@expires_on = I18n.l(invitation.expires_at.to_date, format: :long)
|
||||||
|
|
||||||
|
I18n.with_locale(I18n.locale) do
|
||||||
|
mail(
|
||||||
|
to: invitation.email,
|
||||||
|
subject: t(
|
||||||
|
"mailers.transmission_invite.subject",
|
||||||
|
team: @team.name,
|
||||||
|
club: @club.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
class StreamEvent < ApplicationRecord
|
class StreamEvent < ApplicationRecord
|
||||||
EVENT_TYPES = %w[
|
EVENT_TYPES = %w[
|
||||||
connected disconnected reconnected quality_changed error paused resumed
|
connected disconnected reconnected quality_changed error paused resumed
|
||||||
started ended network_test pairing youtube_ready
|
started ended network_test pairing youtube_ready audio_muted audio_unmuted
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
belongs_to :stream_session
|
belongs_to :stream_session
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Nodo streaming (MediaMTX [+ relay ffmpeg]). Fase 0: registry + assignment URL.
|
||||||
|
class StreamNode < ApplicationRecord
|
||||||
|
ROLES = %w[home cloud lab].freeze
|
||||||
|
STATUSES = %w[provisioning ready draining offline error].freeze
|
||||||
|
PROVIDERS = %w[local proxmox_lab hetzner].freeze
|
||||||
|
|
||||||
|
has_many :stream_sessions, dependent: :nullify
|
||||||
|
|
||||||
|
validates :slug, presence: true, uniqueness: true
|
||||||
|
validates :hostname, presence: true
|
||||||
|
validates :role, inclusion: { in: ROLES }
|
||||||
|
validates :status, inclusion: { in: STATUSES }
|
||||||
|
validates :provider, inclusion: { in: PROVIDERS }
|
||||||
|
validates :rtmp_base_url, :hls_base_url, :api_base_url, presence: true
|
||||||
|
validates :max_publishers, :max_relays, numericality: { greater_than: 0 }
|
||||||
|
|
||||||
|
scope :ready, -> { where(status: "ready") }
|
||||||
|
scope :allocatable, -> { ready }
|
||||||
|
|
||||||
|
# Sessioni che occupano uno slot path MediaMTX su questo nodo.
|
||||||
|
def occupying_sessions
|
||||||
|
stream_sessions.where(status: %w[idle connecting live reconnecting paused])
|
||||||
|
end
|
||||||
|
|
||||||
|
def active_publishers
|
||||||
|
occupying_sessions.count
|
||||||
|
end
|
||||||
|
|
||||||
|
def free_slots
|
||||||
|
[max_publishers - active_publishers, 0].max
|
||||||
|
end
|
||||||
|
|
||||||
|
def allocatable?
|
||||||
|
status == "ready" && free_slots.positive?
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -7,6 +7,7 @@ class StreamSession < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :match
|
belongs_to :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
|
||||||
@@ -75,7 +76,7 @@ class StreamSession < ApplicationRecord
|
|||||||
def rtmp_ingest_url
|
def rtmp_ingest_url
|
||||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||||
"#{MatchLiveTv.mediamtx_rtmp_url}/#{mediamtx_path_name}"
|
"#{rtmp_base_url.chomp('/')}/#{mediamtx_path_name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_path_name
|
def mediamtx_path_name
|
||||||
@@ -91,8 +92,29 @@ class StreamSession < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def hls_playback_url
|
def hls_playback_url
|
||||||
base = MatchLiveTv.hls_public_url.chomp("/")
|
"#{hls_base_url.chomp('/')}/#{effective_hls_path_name}/index.m3u8"
|
||||||
"#{base}/#{effective_hls_path_name}/index.m3u8"
|
end
|
||||||
|
|
||||||
|
def rtmp_base_url
|
||||||
|
stream_node&.rtmp_base_url.presence || MatchLiveTv.mediamtx_rtmp_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def hls_base_url
|
||||||
|
stream_node&.hls_base_url.presence || MatchLiveTv.hls_public_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_api_base_url
|
||||||
|
stream_node&.api_base_url.presence || MatchLiveTv.mediamtx_api_url
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_internal_rtmp_url
|
||||||
|
stream_node&.internal_rtmp_url.presence ||
|
||||||
|
ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_internal_hls_url
|
||||||
|
stream_node&.internal_hls_url.presence ||
|
||||||
|
ENV.fetch("MEDIAMTX_HLS_URL", "http://mediamtx:8888")
|
||||||
end
|
end
|
||||||
|
|
||||||
def effective_hls_path_name
|
def effective_hls_path_name
|
||||||
|
|||||||
@@ -4,19 +4,33 @@ class YoutubeCredential < ApplicationRecord
|
|||||||
attr_encrypted :access_token,
|
attr_encrypted :access_token,
|
||||||
key: :encryption_key,
|
key: :encryption_key,
|
||||||
attribute: "access_token_encrypted",
|
attribute: "access_token_encrypted",
|
||||||
mode: :single_iv_salt
|
mode: :single_iv_and_salt,
|
||||||
|
algorithm: "aes-256-cbc",
|
||||||
|
iv: :encryption_iv
|
||||||
attr_encrypted :refresh_token,
|
attr_encrypted :refresh_token,
|
||||||
key: :encryption_key,
|
key: :encryption_key,
|
||||||
attribute: "refresh_token_encrypted",
|
attribute: "refresh_token_encrypted",
|
||||||
mode: :single_iv_salt
|
mode: :single_iv_and_salt,
|
||||||
|
algorithm: "aes-256-cbc",
|
||||||
|
iv: :encryption_iv
|
||||||
|
|
||||||
def expired?
|
def expired?
|
||||||
expires_at.present? && expires_at < Time.current
|
expires_at.present? && expires_at < Time.current
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def usable?
|
||||||
|
refresh_token.present?
|
||||||
|
rescue ArgumentError, OpenSSL::Cipher::CipherError, NoMethodError
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def encryption_key
|
def encryption_key
|
||||||
Rails.application.secret_key_base[0, 32]
|
Rails.application.secret_key_base[0, 32]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def encryption_iv
|
||||||
|
encryption_key[0, 16]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,9 +5,13 @@ module Billing
|
|||||||
|
|
||||||
AMOUNT_HINTS = {
|
AMOUNT_HINTS = {
|
||||||
500 => %w[premium_light monthly],
|
500 => %w[premium_light monthly],
|
||||||
|
790 => %w[premium_light monthly],
|
||||||
4000 => %w[premium_light yearly],
|
4000 => %w[premium_light yearly],
|
||||||
|
5900 => %w[premium_light yearly],
|
||||||
2000 => %w[premium_full monthly],
|
2000 => %w[premium_full monthly],
|
||||||
20_000 => %w[premium_full yearly]
|
2490 => %w[premium_full monthly],
|
||||||
|
20_000 => %w[premium_full yearly],
|
||||||
|
19_900 => %w[premium_full yearly]
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ module Billing
|
|||||||
|
|
||||||
def button_label(plan:, interval:, kind:)
|
def button_label(plan:, interval:, kind:)
|
||||||
price = PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
price = PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
||||||
|
name = I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
|
||||||
if kind == :checkout
|
if kind == :checkout
|
||||||
I18n.t("billing.messages.activate_button", plan: plan.name, price: price)
|
I18n.t("billing.messages.activate_button", plan: name, price: price)
|
||||||
else
|
else
|
||||||
I18n.t("billing.messages.switch_button", plan: plan.name, price: price)
|
I18n.t("billing.messages.switch_button", plan: name, price: price)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,16 @@ module Billing
|
|||||||
INTERVALS = %w[monthly yearly].freeze
|
INTERVALS = %w[monthly yearly].freeze
|
||||||
DEFAULT_INTERVAL = "yearly"
|
DEFAULT_INTERVAL = "yearly"
|
||||||
|
|
||||||
PRICING = {
|
# Importi in centesimi (EUR). Il yearly è il prezzo lancio addebitato.
|
||||||
"premium_light" => { "yearly" => "€40/anno", "monthly" => "€5/mese" },
|
AMOUNTS = {
|
||||||
"premium_full" => { "yearly" => "€200/anno", "monthly" => "€20/mese" }
|
"premium_light" => {
|
||||||
|
"yearly" => { charge: 5900, list: 7900, equivalent_monthly: 492 },
|
||||||
|
"monthly" => { charge: 790 }
|
||||||
|
},
|
||||||
|
"premium_full" => {
|
||||||
|
"yearly" => { charge: 19_900, list: 24_900, equivalent_monthly: 1658 },
|
||||||
|
"monthly" => { charge: 2490 }
|
||||||
|
}
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
@@ -30,7 +37,23 @@ module Billing
|
|||||||
end
|
end
|
||||||
|
|
||||||
def label(plan_slug:, interval:)
|
def label(plan_slug:, interval:)
|
||||||
PRICING.dig(plan_slug.to_s, normalize_interval(interval)) || normalize_interval(interval)
|
interval = normalize_interval(interval)
|
||||||
|
format_interval_price(charge_cents(plan_slug, interval), interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_label(plan_slug:, interval: "yearly")
|
||||||
|
interval = normalize_interval(interval)
|
||||||
|
cents = list_cents(plan_slug, interval)
|
||||||
|
return nil if cents.blank?
|
||||||
|
|
||||||
|
format_interval_price(cents, interval)
|
||||||
|
end
|
||||||
|
|
||||||
|
def equivalent_monthly_label(plan_slug:)
|
||||||
|
cents = AMOUNTS.dig(plan_slug.to_s, "yearly", :equivalent_monthly)
|
||||||
|
return nil if cents.blank?
|
||||||
|
|
||||||
|
format_interval_price(cents, "monthly")
|
||||||
end
|
end
|
||||||
|
|
||||||
def available_intervals(plan_slug:)
|
def available_intervals(plan_slug:)
|
||||||
@@ -52,8 +75,33 @@ module Billing
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def format_eur(cents)
|
||||||
|
cents = cents.to_i
|
||||||
|
whole, frac = cents.divmod(100)
|
||||||
|
if frac.zero?
|
||||||
|
"€#{whole}"
|
||||||
|
else
|
||||||
|
sep = I18n.locale.to_s == "en" ? "." : ","
|
||||||
|
"€#{whole}#{sep}#{frac.to_s.rjust(2, '0')}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def charge_cents(plan_slug, interval)
|
||||||
|
AMOUNTS.dig(plan_slug.to_s, interval, :charge)
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_cents(plan_slug, interval)
|
||||||
|
AMOUNTS.dig(plan_slug.to_s, interval, :list)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_interval_price(cents, interval)
|
||||||
|
return nil if cents.blank?
|
||||||
|
|
||||||
|
I18n.t("billing.prices.#{interval}", amount: format_eur(cents))
|
||||||
|
end
|
||||||
|
|
||||||
def price_id_for(plan_slug, interval)
|
def price_id_for(plan_slug, interval)
|
||||||
case [plan_slug, interval]
|
case [plan_slug, interval]
|
||||||
when %w[premium_light monthly]
|
when %w[premium_light monthly]
|
||||||
|
|||||||
@@ -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,11 +17,12 @@ 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
|
||||||
# solo la slate (schermo nero) in pausa/attesa.
|
# solo la slate in pausa/attesa. Slate resta accesa anche su YouTube (copertina se l'app cade).
|
||||||
# YouTube: niente slate sul path camera (maschera il video al relay ffmpeg).
|
|
||||||
body = recording_body(session, enabled: false).merge(
|
body = recording_body(session, enabled: false).merge(
|
||||||
source: "publisher",
|
source: "publisher",
|
||||||
overridePublisher: true
|
overridePublisher: true
|
||||||
@@ -69,7 +75,7 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
|
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
|
||||||
# Disattivare quando il publisher è in onda; riattivare in pausa/disconnessione.
|
# Resta accesa anche con publisher in onda (MediaMTX usa il publisher se presente).
|
||||||
def set_always_available(session, enabled:)
|
def set_always_available(session, enabled:)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
return true if always_available_remembered?(path, enabled: enabled)
|
return true if always_available_remembered?(path, enabled: enabled)
|
||||||
@@ -98,6 +104,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,14 +12,13 @@ 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)
|
||||||
if @session.paused?
|
if @session.paused?
|
||||||
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
# RTMP ancora connesso in pausa: non forzare live/reconnect.
|
||||||
else
|
else
|
||||||
enable_live_path_once!(@session)
|
|
||||||
if @session.may_go_live?
|
if @session.may_go_live?
|
||||||
@session.go_live!
|
@session.go_live!
|
||||||
@session.reload
|
@session.reload
|
||||||
@@ -75,27 +74,14 @@ module Mediamtx
|
|||||||
@session.update!(timeout_job_id: job)
|
@session.update!(timeout_job_id: job)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Compatibilità con webhook / controller.
|
|
||||||
def self.schedule_youtube_pipeline!(session, force: false)
|
def self.schedule_youtube_pipeline!(session, force: false)
|
||||||
Youtube::LivePipeline.schedule!(session, force: force)
|
Youtube::LivePipeline.schedule!(session, force: force)
|
||||||
end
|
end
|
||||||
|
|
||||||
def enable_live_path_once!(session)
|
|
||||||
return unless session.platform == "youtube"
|
|
||||||
|
|
||||||
key = format("youtube:slate_disabled:%s", session.id)
|
|
||||||
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
|
|
||||||
|
|
||||||
Client.new.set_always_available(session, enabled: false)
|
|
||||||
rescue Client::Error => e
|
|
||||||
redis.del(format("youtube:slate_disabled:%s", session.id))
|
|
||||||
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
|
|
||||||
end
|
|
||||||
|
|
||||||
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 +114,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 +169,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)
|
Streams::YoutubeRelay.enqueue_ensure!(session)
|
||||||
end
|
end
|
||||||
|
|
||||||
def redis
|
def redis
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ module Ops
|
|||||||
check_sidekiq_heartbeat,
|
check_sidekiq_heartbeat,
|
||||||
check_sidekiq_dead,
|
check_sidekiq_dead,
|
||||||
check_http_rails,
|
check_http_rails,
|
||||||
check_rails_latency
|
check_rails_latency,
|
||||||
|
check_stream_overflow
|
||||||
]
|
]
|
||||||
findings << check_http_public if public_check_due?
|
findings << check_http_public if public_check_due?
|
||||||
findings
|
findings
|
||||||
@@ -161,6 +162,58 @@ module Ops
|
|||||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def check_stream_overflow
|
||||||
|
return ok_finding("stream_overflow:skip", "Nodi stream non migrati") unless ActiveRecord::Base.connection.data_source_exists?("stream_nodes")
|
||||||
|
|
||||||
|
orphan_hours = ENV.fetch("STREAM_OVERFLOW_ORPHAN_HOURS", "3").to_i
|
||||||
|
orphans = StreamNode.where.not(slug: "home").where(status: %w[ready draining]).select do |n|
|
||||||
|
n.active_publishers.zero? && n.created_at < orphan_hours.hours.ago
|
||||||
|
end
|
||||||
|
metrics = Streams::Autoscaler.metrics
|
||||||
|
over_budget = !metrics[:within_budget]
|
||||||
|
at_max = metrics[:overflow_nodes] >= metrics[:max_overflow_nodes] && metrics[:free_slots] <= metrics[:soft_free_slots]
|
||||||
|
|
||||||
|
if orphans.any?
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Nodi stream overflow idle",
|
||||||
|
message: "#{orphans.size} nodo/i idle da >#{orphan_hours}h: #{orphans.map(&:slug).join(', ')}",
|
||||||
|
metadata: { "slugs" => orphans.map(&:slug) },
|
||||||
|
fingerprint: "stream_overflow:orphan_idle"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if over_budget
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Budget overflow streaming",
|
||||||
|
message: "Stima €#{metrics[:estimated_monthly_eur]}/mese > budget €#{metrics[:monthly_budget_eur]}",
|
||||||
|
metadata: metrics.transform_keys(&:to_s),
|
||||||
|
fingerprint: "stream_overflow:budget"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if at_max
|
||||||
|
return Finding.new(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
healthy: false,
|
||||||
|
title: "Capacità stream al massimo",
|
||||||
|
message: "overflow=#{metrics[:overflow_nodes]}/#{metrics[:max_overflow_nodes]} free_slots=#{metrics[:free_slots]}",
|
||||||
|
metadata: metrics.transform_keys(&:to_s),
|
||||||
|
fingerprint: "stream_overflow:at_max"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
ok_finding("stream_overflow:ok", "Overflow streaming OK")
|
||||||
|
rescue StandardError => e
|
||||||
|
fail_finding("stream_overflow", "warning", "stream_overflow:error", "Check overflow fallito", e.message)
|
||||||
|
end
|
||||||
|
|
||||||
def check_sidekiq_heartbeat
|
def check_sidekiq_heartbeat
|
||||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
last = redis.get(Ops::HealthMonitorJob::HEARTBEAT_KEY).to_i
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module Recordings
|
|||||||
def cleanup_mediamtx_path(session)
|
def cleanup_mediamtx_path(session)
|
||||||
return unless session.status.in?(%w[ended error])
|
return unless session.status.in?(%w[ended error])
|
||||||
|
|
||||||
Mediamtx::Client.new.delete_path(session)
|
Mediamtx::Client.for_session(session).delete_path(session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
@logger.warn("[Recordings::CleanupLocal] delete_path #{session.id}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ module Recordings
|
|||||||
|
|
||||||
def cleanup_mediamtx_path
|
def cleanup_mediamtx_path
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
Mediamtx::Client.new.delete_path(@session)
|
Mediamtx::Client.for_session(@session).delete_path(@session)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -34,11 +34,19 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
StreamSession.transaction do
|
StreamSession.transaction do
|
||||||
|
session.stream_node = Streams::NodeRegistry.allocate!
|
||||||
session.save!
|
session.save!
|
||||||
Scoring::Engine.ensure_score_for(session)
|
Scoring::Engine.ensure_score_for(session)
|
||||||
mtx = Mediamtx::Client.new
|
Mediamtx::Client.for_session(session).create_path(session)
|
||||||
mtx.create_path(session)
|
log_event(
|
||||||
log_event(session, "pairing", { created: true, platform: session.platform })
|
session,
|
||||||
|
"pairing",
|
||||||
|
{
|
||||||
|
created: true,
|
||||||
|
platform: session.platform,
|
||||||
|
stream_node: session.stream_node&.slug
|
||||||
|
}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
if session.platform == "youtube"
|
if session.platform == "youtube"
|
||||||
@@ -46,6 +54,8 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
session
|
session
|
||||||
|
rescue Streams::NodeRegistry::NoCapacityError => e
|
||||||
|
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ module Sessions
|
|||||||
@session.pause! if @session.may_pause?
|
@session.pause! if @session.may_pause?
|
||||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||||
begin
|
begin
|
||||||
mtx = Mediamtx::Client.new
|
mtx = Mediamtx::Client.for_session(@session)
|
||||||
mtx.set_path_recording(@session, enabled: false)
|
mtx.set_path_recording(@session, enabled: false)
|
||||||
mtx.set_always_available(@session, enabled: true)
|
mtx.set_always_available(@session, enabled: true)
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module Sessions
|
||||||
|
# Silenzia o riattiva il microfono della camera in onda.
|
||||||
|
# Lo stream continua a inviare AAC silenzioso (YouTube/MediaMTX richiedono la traccia audio).
|
||||||
|
class SetAudioMute
|
||||||
|
def initialize(session, muted:)
|
||||||
|
@session = session
|
||||||
|
@muted = ActiveModel::Type::Boolean.new.cast(muted)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return @session if @session.audio_muted == @muted
|
||||||
|
|
||||||
|
@session.update!(audio_muted: @muted)
|
||||||
|
action = @muted ? "mute_audio" : "unmute_audio"
|
||||||
|
event_type = @muted ? "audio_muted" : "audio_unmuted"
|
||||||
|
log_event(event_type)
|
||||||
|
SessionChannel.broadcast_message(@session, { type: "command", action: action, muted: @muted })
|
||||||
|
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "audio_muted", muted: @muted })
|
||||||
|
@session
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def log_event(type)
|
||||||
|
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: { muted: @muted })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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,200 @@
|
|||||||
|
# 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 = {})
|
||||||
|
tries = 0
|
||||||
|
begin
|
||||||
|
response = conn.get(path) do |req|
|
||||||
|
req.headers.update(auth_headers)
|
||||||
|
req.params.update(params)
|
||||||
|
end
|
||||||
|
unwrap!(response)
|
||||||
|
rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError
|
||||||
|
tries += 1
|
||||||
|
raise if tries >= 5
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
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,184 @@
|
|||||||
|
# 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"])
|
||||||
|
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
|
||||||
|
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
|
||||||
|
|
||||||
|
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: urls.fetch(:rtmp_base_url),
|
||||||
|
hls_base_url: urls.fetch(:hls_base_url),
|
||||||
|
api_base_url: urls.fetch(:api_base_url),
|
||||||
|
internal_rtmp_url: urls.fetch(:internal_rtmp_url),
|
||||||
|
internal_hls_url: urls.fetch(:internal_hls_url),
|
||||||
|
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 urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
|
||||||
|
if role == "lab" && ENV["MEDIAMTX_LAB_API_URL"].present?
|
||||||
|
return {
|
||||||
|
api_base_url: ENV.fetch("MEDIAMTX_LAB_API_URL"),
|
||||||
|
internal_rtmp_url: ENV.fetch("MEDIAMTX_LAB_INTERNAL_RTMP_URL", "rtmp://mediamtx_lab:1935"),
|
||||||
|
internal_hls_url: ENV.fetch("MEDIAMTX_LAB_HLS_URL", "http://mediamtx_lab:8888"),
|
||||||
|
rtmp_base_url: ENV.fetch("MEDIAMTX_LAB_RTMP_URL", "rtmp://127.0.0.1:11935"),
|
||||||
|
# HLS pubblico resta sul proxy del sito: il controller instrada al MediaMTX del nodo.
|
||||||
|
hls_base_url: home.hls_base_url
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
control_ip = control_ip_for(role: role, private_ip: private_ip, public_ip: public_ip)
|
||||||
|
{
|
||||||
|
api_base_url: simulated ? home.api_base_url : "http://#{control_ip}:9997",
|
||||||
|
internal_rtmp_url: simulated ? home.internal_rtmp_url : "rtmp://#{control_ip}:1935",
|
||||||
|
internal_hls_url: simulated ? home.internal_hls_url : "http://#{control_ip}:8888",
|
||||||
|
rtmp_base_url: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url,
|
||||||
|
# Player del sito: proxy Rails/edge. HTTPS diretto sul nodo arriva dopo TLS/Caddy.
|
||||||
|
hls_base_url: home.hls_base_url
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Senza WireGuard/network privata Rails deve parlare con l'IPv4 pubblico (API+HLS).
|
||||||
|
def control_ip_for(role:, private_ip:, public_ip:)
|
||||||
|
public_ip = public_ip.presence
|
||||||
|
return private_ip if role != "cloud" || public_ip.blank?
|
||||||
|
return public_ip if ENV["STREAM_CLOUD_PUBLIC_CONTROL"] == "1"
|
||||||
|
return public_ip if private_ip.blank? || private_ip == public_ip
|
||||||
|
|
||||||
|
private_ip
|
||||||
|
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,66 @@
|
|||||||
|
require "json"
|
||||||
|
require "net/http"
|
||||||
|
require "uri"
|
||||||
|
|
||||||
module Streams
|
module Streams
|
||||||
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay.
|
# Relay verso YouTube: legge HLS da MediaMTX e inoltra su RTMPS (-c copy).
|
||||||
# ffmpeg gira solo nel container sidekiq (YOUTUBE_RELAY_WORKER=1).
|
# Sul nodo cloud ffmpeg è avviato dall'agent locale (immagine CPX) all'avvio diretta.
|
||||||
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
|
||||||
|
QUEUE_PREFIX = "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 local_node_slug
|
||||||
|
ENV["STREAM_NODE_SLUG"].presence || NodeRegistry::HOME_SLUG
|
||||||
|
end
|
||||||
|
|
||||||
|
def assigned_node_slug(session)
|
||||||
|
session.stream_node&.slug.presence || NodeRegistry::HOME_SLUG
|
||||||
|
end
|
||||||
|
|
||||||
|
def node_matches?(session)
|
||||||
|
assigned_node_slug(session) == local_node_slug
|
||||||
|
end
|
||||||
|
|
||||||
|
def queue_for(session)
|
||||||
|
"#{QUEUE_PREFIX}_#{assigned_node_slug(session)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue_ensure!(session, wait: nil)
|
||||||
|
opts = { queue: queue_for(session) }
|
||||||
|
opts[:wait] = wait if wait
|
||||||
|
YoutubeRelayEnsureJob.set(**opts).perform_later(session.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def enqueue_stop!(session)
|
||||||
|
YoutubeRelayStopJob.set(queue: queue_for(session)).perform_later(session.id)
|
||||||
|
end
|
||||||
|
|
||||||
def start(session)
|
def start(session)
|
||||||
return unless worker?
|
return enqueue_ensure!(session) unless worker? && node_matches?(session)
|
||||||
|
|
||||||
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) && node_matches?(session)
|
||||||
redis.del(format(OWNER_KEY, session.id))
|
|
||||||
if worker?
|
|
||||||
stop_on_worker!(session)
|
stop_on_worker!(session)
|
||||||
else
|
else
|
||||||
YoutubeRelayStopJob.perform_later(session.id)
|
enqueue_stop!(session)
|
||||||
end
|
end
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
@@ -31,11 +68,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, session_id: session_id) 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
|
||||||
|
|
||||||
@@ -44,15 +83,22 @@ module Streams
|
|||||||
return if session.terminal?
|
return if session.terminal?
|
||||||
return if session.stream_key.blank?
|
return if session.stream_key.blank?
|
||||||
|
|
||||||
if worker?
|
if worker? && node_matches?(session)
|
||||||
ensure_on_worker!(session)
|
ensure_on_worker!(session)
|
||||||
else
|
else
|
||||||
YoutubeRelayEnsureJob.perform_later(session.id)
|
enqueue_ensure!(session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_on_worker!(session)
|
def ensure_on_worker!(session)
|
||||||
return unless worker?
|
return :not_worker unless worker?
|
||||||
|
unless node_matches?(session)
|
||||||
|
enqueue_ensure!(session, wait: 2.seconds)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[YoutubeRelay] wrong_node local=#{local_node_slug} assigned=#{assigned_node_slug(session)} session=#{session.id}"
|
||||||
|
)
|
||||||
|
return :wrong_host
|
||||||
|
end
|
||||||
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 +106,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, session_id: session.id)
|
||||||
|
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?
|
||||||
|
enqueue_ensure!(session, wait: 5.seconds)
|
||||||
|
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} 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)
|
||||||
pid = pid_for(session.id)
|
return :not_worker unless worker?
|
||||||
return false if pid.blank?
|
|
||||||
|
|
||||||
terminate_pid(pid)
|
owner = redis.get(format(OWNER_KEY, session.id))
|
||||||
clear_pid(session.id)
|
if owner.present? && owner != worker_id
|
||||||
redis.del(format(OWNER_KEY, session.id))
|
return :wrong_host
|
||||||
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}")
|
end
|
||||||
true
|
|
||||||
|
pid = pid_for(session.id)
|
||||||
|
if pid.blank?
|
||||||
|
clear_local_ownership(session.id)
|
||||||
|
return :noop
|
||||||
|
end
|
||||||
|
|
||||||
|
terminate_pid(pid, session_id: session.id)
|
||||||
|
clear_local_ownership(session.id)
|
||||||
|
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
|
||||||
|
:stopped
|
||||||
|
end
|
||||||
|
|
||||||
|
def local_owned_count
|
||||||
|
redis.scard(format(OWNED_SET, worker_id)).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def at_capacity?
|
||||||
|
local_owned_count >= max_concurrent
|
||||||
|
end
|
||||||
|
|
||||||
|
def owner_is_local?(session_id)
|
||||||
|
owner = redis.get(format(OWNER_KEY, session_id))
|
||||||
|
owner.blank? || owner == worker_id
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -92,30 +174,92 @@ 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)
|
||||||
|
|
||||||
|
# Due EnsureJob concorrenti (concurrency Sidekiq > 1) non devono spawnare due ffmpeg.
|
||||||
|
unless redis.set(format("youtube_relay:startlock:%s", session.id), worker_id, nx: true, ex: 20)
|
||||||
|
return pid_for(session.id).to_i
|
||||||
|
end
|
||||||
|
|
||||||
log_path = log_file(session)
|
log_path = log_file(session)
|
||||||
FileUtils.mkdir_p(File.dirname(log_path))
|
FileUtils.mkdir_p(File.dirname(log_path))
|
||||||
intake_source = mediamtx_intake_source(session)
|
intake_source = mediamtx_intake_source(session)
|
||||||
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
|
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
|
||||||
|
|
||||||
pid = Process.spawn(
|
pid = if relay_agent_enabled?
|
||||||
*youtube_ffmpeg_args(intake_source, output),
|
start_via_agent!(session)
|
||||||
%i[out err] => log_path,
|
else
|
||||||
pgroup: true
|
spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path)
|
||||||
)
|
end
|
||||||
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} agent=#{relay_agent_url || '-'}"
|
||||||
|
)
|
||||||
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
|
||||||
|
|
||||||
|
def relay_agent_url
|
||||||
|
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def relay_agent_enabled?
|
||||||
|
relay_agent_url.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def relay_agent_secret
|
||||||
|
ENV["STREAM_NODE_AGENT_SECRET"].presence || ENV["MEDIAMTX_WEBHOOK_SECRET"].presence || ""
|
||||||
|
end
|
||||||
|
|
||||||
|
def spawn_ffmpeg!(args, log_path)
|
||||||
|
pid = Process.spawn(*args, %i[out err] => log_path, pgroup: true)
|
||||||
|
Process.detach(pid)
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
def start_via_agent!(session)
|
||||||
|
payload = {
|
||||||
|
"session_id" => session.id,
|
||||||
|
"path" => session.mediamtx_path_name,
|
||||||
|
"rtmps" => "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
|
||||||
|
}
|
||||||
|
res = agent_request(Net::HTTP::Post, "/relays", payload)
|
||||||
|
pid = res["pid"].to_i
|
||||||
|
raise Error, "relay agent spawn failed: #{res.inspect}" if pid <= 0
|
||||||
|
|
||||||
|
File.write(
|
||||||
|
log_file(session),
|
||||||
|
"agent=#{relay_agent_url} pid=#{pid} path=#{session.mediamtx_path_name}\n"
|
||||||
|
)
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
def agent_request(http_class, path, payload = nil)
|
||||||
|
uri = URI.parse("#{relay_agent_url.chomp('/')}#{path}")
|
||||||
|
http = Net::HTTP.new(uri.host, uri.port)
|
||||||
|
http.open_timeout = 5
|
||||||
|
http.read_timeout = 10
|
||||||
|
req = http_class.new(uri)
|
||||||
|
req["Authorization"] = "Bearer #{relay_agent_secret}" if relay_agent_secret.present?
|
||||||
|
req["Content-Type"] = "application/json"
|
||||||
|
req.body = JSON.generate(payload) if payload
|
||||||
|
res = http.request(req)
|
||||||
|
body = res.body.present? ? JSON.parse(res.body) : {}
|
||||||
|
unless res.is_a?(Net::HTTPSuccess)
|
||||||
|
raise Error, "relay agent #{path} HTTP #{res.code} #{body.inspect}"
|
||||||
|
end
|
||||||
|
|
||||||
|
body
|
||||||
|
rescue JSON::ParserError => e
|
||||||
|
raise Error, "relay agent JSON: #{e.message}"
|
||||||
|
end
|
||||||
|
|
||||||
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
|
# 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.
|
# 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)
|
||||||
@@ -135,6 +279,9 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
common_head + [
|
common_head + [
|
||||||
|
"-reconnect", "1",
|
||||||
|
"-reconnect_streamed", "1",
|
||||||
|
"-reconnect_delay_max", "2",
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-live_start_index", "-1",
|
"-live_start_index", "-1",
|
||||||
"-i", url,
|
"-i", url,
|
||||||
@@ -146,20 +293,35 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def mediamtx_intake_source(session)
|
def mediamtx_intake_source(session)
|
||||||
base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935")
|
rtmp_base, hls_base = intake_bases(session)
|
||||||
if Mediamtx::PublisherOnline.active?(session)
|
if Mediamtx::PublisherOnline.active?(session)
|
||||||
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"]
|
return [:rtmp, "#{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, "#{hls_base.chomp('/')}/#{path}/index.m3u8"]
|
||||||
[:hls, "#{hls}/#{path}/index.m3u8"]
|
end
|
||||||
|
|
||||||
|
# Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale).
|
||||||
|
def intake_bases(session)
|
||||||
|
if relay_agent_enabled? && node_matches?(session)
|
||||||
|
return ["rtmp://127.0.0.1:1935", "http://127.0.0.1:8888"]
|
||||||
|
end
|
||||||
|
|
||||||
|
if node_matches?(session)
|
||||||
|
[
|
||||||
|
ENV["STREAM_NODE_LOCAL_RTMP_URL"].presence || session.mediamtx_internal_rtmp_url,
|
||||||
|
ENV["STREAM_NODE_LOCAL_HLS_URL"].presence || session.mediamtx_internal_hls_url
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[session.mediamtx_internal_rtmp_url, session.mediamtx_internal_hls_url]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def intake_available?(session)
|
def intake_available?(session)
|
||||||
return true if Mediamtx::PublisherOnline.active?(session)
|
return true if Mediamtx::PublisherOnline.active?(session)
|
||||||
|
|
||||||
info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
info = Mediamtx::Client.for_session(session).list_paths.find { |i| i["name"] == session.mediamtx_path_name }
|
||||||
info && (info["ready"] || info["online"] || info["available"])
|
info && (info["ready"] || info["online"] || info["available"])
|
||||||
rescue StandardError
|
rescue StandardError
|
||||||
false
|
false
|
||||||
@@ -185,6 +347,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
|
||||||
@@ -197,7 +375,9 @@ module Streams
|
|||||||
redis.get(format(REDIS_KEY, session_id))
|
redis.get(format(REDIS_KEY, session_id))
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_alive?(pid)
|
def process_alive?(pid, session_id: nil)
|
||||||
|
return agent_session_running?(session_id) if relay_agent_enabled? && session_id.present?
|
||||||
|
|
||||||
stat = File.read("/proc/#{pid.to_i}/stat")
|
stat = File.read("/proc/#{pid.to_i}/stat")
|
||||||
return false if stat.split[2] == "Z"
|
return false if stat.split[2] == "Z"
|
||||||
|
|
||||||
@@ -207,12 +387,26 @@ module Streams
|
|||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def terminate_pid(pid)
|
def agent_session_running?(session_id)
|
||||||
|
res = agent_request(Net::HTTP::Get, "/relays/#{session_id}")
|
||||||
|
res["running"] == true
|
||||||
|
rescue Error
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def terminate_pid(pid, session_id: nil)
|
||||||
|
if relay_agent_enabled? && session_id.present?
|
||||||
|
agent_request(Net::HTTP::Delete, "/relays/#{session_id}")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
Process.kill("TERM", -pid.to_i)
|
Process.kill("TERM", -pid.to_i)
|
||||||
sleep 0.5
|
sleep 0.5
|
||||||
rescue Errno::ESRCH
|
rescue Errno::ESRCH
|
||||||
nil
|
nil
|
||||||
else
|
else
|
||||||
|
return if relay_agent_enabled?
|
||||||
|
|
||||||
begin
|
begin
|
||||||
Process.kill("KILL", -pid.to_i)
|
Process.kill("KILL", -pid.to_i)
|
||||||
rescue Errno::ESRCH
|
rescue Errno::ESRCH
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
module Teams
|
module Teams
|
||||||
class StaffAssignmentError < StandardError
|
|
||||||
attr_reader :message
|
|
||||||
|
|
||||||
def initialize(message)
|
|
||||||
@message = message
|
|
||||||
super(message)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class StaffAssignment
|
class StaffAssignment
|
||||||
def self.call(team:, user:, staff_kind: "transmission", membership: nil)
|
def self.call(team:, user:, staff_kind: "transmission", membership: nil)
|
||||||
new(team: team, user: user, staff_kind: staff_kind, membership: membership).call
|
new(team: team, user: user, staff_kind: staff_kind, membership: membership).call
|
||||||
@@ -33,31 +24,4 @@ module Teams
|
|||||||
ut
|
ut
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
class StaffEmailValidator
|
|
||||||
def self.assert_available!(team:, email:, except_user: nil, staff_kind: nil)
|
|
||||||
new(team: team, email: email, except_user: except_user).assert_available!
|
|
||||||
end
|
|
||||||
|
|
||||||
def initialize(team:, email:, except_user: nil)
|
|
||||||
@team = team
|
|
||||||
@email = email.to_s.downcase.strip
|
|
||||||
@except_user = except_user
|
|
||||||
end
|
|
||||||
|
|
||||||
def assert_available!
|
|
||||||
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where.not(staff_kind: nil)
|
|
||||||
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
|
|
||||||
if user_scope.exists?
|
|
||||||
raise StaffAssignmentError,
|
|
||||||
"L'email #{@email} è già assegnata come responsabile trasmissione per questa squadra."
|
|
||||||
end
|
|
||||||
|
|
||||||
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email)
|
|
||||||
return unless inv.exists?
|
|
||||||
|
|
||||||
raise StaffAssignmentError,
|
|
||||||
"Esiste già un invito in sospeso per #{@email}."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module Teams
|
||||||
|
class StaffAssignmentError < StandardError
|
||||||
|
attr_reader :message
|
||||||
|
|
||||||
|
def initialize(message)
|
||||||
|
@message = message
|
||||||
|
super(message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
module Teams
|
||||||
|
class StaffEmailValidator
|
||||||
|
def self.assert_available!(team:, email:, except_user: nil, staff_kind: nil)
|
||||||
|
new(team: team, email: email, except_user: except_user).assert_available!
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(team:, email:, except_user: nil)
|
||||||
|
@team = team
|
||||||
|
@email = email.to_s.downcase.strip
|
||||||
|
@except_user = except_user
|
||||||
|
end
|
||||||
|
|
||||||
|
def assert_available!
|
||||||
|
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where.not(staff_kind: nil)
|
||||||
|
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
|
||||||
|
if user_scope.exists?
|
||||||
|
raise StaffAssignmentError,
|
||||||
|
"L'email #{@email} è già assegnata come responsabile trasmissione per questa squadra."
|
||||||
|
end
|
||||||
|
|
||||||
|
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email)
|
||||||
|
return unless inv.exists?
|
||||||
|
|
||||||
|
raise StaffAssignmentError,
|
||||||
|
"Esiste già un invito in sospeso per #{@email}."
|
||||||
|
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
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ module Youtube
|
|||||||
end
|
end
|
||||||
|
|
||||||
def team_channel_available?
|
def team_channel_available?
|
||||||
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.club.youtube_credential.present?
|
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" &&
|
||||||
|
@team.club.youtube_credential&.usable?
|
||||||
end
|
end
|
||||||
|
|
||||||
def effective_channel
|
def effective_channel
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ module Youtube
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
Mediamtx::Client.new.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?
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ module Youtube
|
|||||||
when "matchlivetv_light"
|
when "matchlivetv_light"
|
||||||
PlatformCredential.configured?
|
PlatformCredential.configured?
|
||||||
when "team"
|
when "team"
|
||||||
@team.club.youtube_credential.present? || PlatformCredential.configured?
|
@team.club.youtube_credential&.usable? || PlatformCredential.configured?
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
@@ -31,7 +31,7 @@ module Youtube
|
|||||||
if @mode == "matchlivetv_light"
|
if @mode == "matchlivetv_light"
|
||||||
PlatformCredential.configured?
|
PlatformCredential.configured?
|
||||||
elsif @mode == "team"
|
elsif @mode == "team"
|
||||||
@team.club.youtube_credential.blank? && PlatformCredential.configured?
|
!@team.club.youtube_credential&.usable? && PlatformCredential.configured?
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
@@ -46,7 +46,7 @@ module Youtube
|
|||||||
end
|
end
|
||||||
|
|
||||||
def needs_team_oauth?
|
def needs_team_oauth?
|
||||||
@mode == "team" && @ent.premium_full? && @team.club.youtube_credential.blank? && !PlatformCredential.configured?
|
@mode == "team" && @ent.premium_full? && !@team.club.youtube_credential&.usable? && !PlatformCredential.configured?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body style="margin:0;padding:24px;background:#ffffff;color:#1a1a1a;">
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<%= 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=50">
|
<link rel="stylesheet" href="/marketing.css?v=64">
|
||||||
</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" %>
|
||||||
|
|||||||
@@ -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=50">
|
<link rel="stylesheet" href="/marketing.css?v=64">
|
||||||
<link rel="stylesheet" href="/live.css?v=26">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p>
|
<p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p>
|
||||||
<p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p>
|
<p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p>
|
||||||
|
<p id="reconnecting-msg" class="live-status-msg" hidden><%= t("live.show.js_reconnecting_awaiting") %></p>
|
||||||
<p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></p>
|
<p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></p>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -63,25 +64,26 @@
|
|||||||
const offlineMsg = document.getElementById("offline-msg");
|
const offlineMsg = document.getElementById("offline-msg");
|
||||||
const pausedMsg = document.getElementById("paused-msg");
|
const pausedMsg = document.getElementById("paused-msg");
|
||||||
const awaitingMsg = document.getElementById("awaiting-msg");
|
const awaitingMsg = document.getElementById("awaiting-msg");
|
||||||
|
const reconnectingMsg = document.getElementById("reconnecting-msg");
|
||||||
const playHint = document.getElementById("play-hint");
|
const playHint = document.getElementById("play-hint");
|
||||||
const streamBadge = document.getElementById("live-stream-badge");
|
const streamBadge = document.getElementById("live-stream-badge");
|
||||||
|
|
||||||
function syncStreamBadge(data) {
|
function syncStreamBadge(data) {
|
||||||
if (!streamBadge) return;
|
if (!streamBadge) return;
|
||||||
if (data.stream_closed) {
|
if (data.stream_closed) {
|
||||||
streamBadge.textContent = "<%= j t("live.overlays.badge_ended") %>";
|
streamBadge.textContent = <%= raw t("live.overlays.badge_ended").to_json %>;
|
||||||
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const paused = !!(data.paused || data.status === "paused");
|
const paused = !!(data.paused || data.status === "paused");
|
||||||
if (paused) {
|
if (paused) {
|
||||||
streamBadge.textContent = "<%= j t("live.index.badge_paused") %>";
|
streamBadge.textContent = <%= raw t("live.index.badge_paused").to_json %>;
|
||||||
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
||||||
} else if (data.on_air) {
|
} else if (data.on_air) {
|
||||||
streamBadge.textContent = "<%= j t("live.index.badge_live") %>";
|
streamBadge.textContent = <%= raw t("live.index.badge_live").to_json %>;
|
||||||
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
|
||||||
} else {
|
} else {
|
||||||
streamBadge.textContent = "<%= j t("live.overlays.badge_waiting") %>";
|
streamBadge.textContent = <%= raw t("live.overlays.badge_waiting").to_json %>;
|
||||||
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,9 +284,10 @@
|
|||||||
if (onAir || sessionLive) {
|
if (onAir || sessionLive) {
|
||||||
offlineMsg.hidden = true;
|
offlineMsg.hidden = true;
|
||||||
if (!publisherOnline && showingCover && !paused) {
|
if (!publisherOnline && showingCover && !paused) {
|
||||||
awaitingMsg.hidden = false;
|
awaitingMsg.hidden = true;
|
||||||
awaitingMsg.textContent = "<%= j t("live.show.js_reconnecting_awaiting") %>";
|
if (reconnectingMsg) reconnectingMsg.hidden = false;
|
||||||
} else {
|
} else {
|
||||||
|
if (reconnectingMsg) reconnectingMsg.hidden = true;
|
||||||
awaitingMsg.hidden = !awaitingSignal;
|
awaitingMsg.hidden = !awaitingSignal;
|
||||||
}
|
}
|
||||||
pausedMsg.hidden = !paused;
|
pausedMsg.hidden = !paused;
|
||||||
@@ -299,6 +302,7 @@
|
|||||||
} else {
|
} else {
|
||||||
offlineMsg.hidden = false;
|
offlineMsg.hidden = false;
|
||||||
awaitingMsg.hidden = true;
|
awaitingMsg.hidden = true;
|
||||||
|
if (reconnectingMsg) reconnectingMsg.hidden = true;
|
||||||
pausedMsg.hidden = true;
|
pausedMsg.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,63 +2,283 @@
|
|||||||
<% content_for :meta_description, t("pages.features.meta_description") %>
|
<% content_for :meta_description, t("pages.features.meta_description") %>
|
||||||
<% content_for :canonical_url, seo_absolute_url(public_features_path) %>
|
<% content_for :canonical_url, seo_absolute_url(public_features_path) %>
|
||||||
|
|
||||||
<div class="wrap">
|
<article class="features-page">
|
||||||
<h1 style="margin-top:24px"><%= t("pages.features.title") %></h1>
|
<header class="features-hero">
|
||||||
<p style="color:#aaa;max-width:640px"><%= t("pages.features.lead") %></p>
|
<div class="wrap features-hero__grid">
|
||||||
|
<div class="features-hero__copy">
|
||||||
|
<p class="features-hero__eyebrow">Match Live TV</p>
|
||||||
|
<h1><%= t("pages.features.hero_title") %></h1>
|
||||||
|
<p class="features-hero__lead"><%= t("pages.features.hero_lead") %></p>
|
||||||
|
|
||||||
<section class="section">
|
<ol class="features-flow" aria-label="<%= t("pages.features.flow_aria") %>">
|
||||||
<h2><%= t("pages.features.staff_section_title") %></h2>
|
<li class="features-flow__item">
|
||||||
<div class="features-grid">
|
<span class="features-flow__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
||||||
<div class="feature-card">
|
<span class="features-flow__label"><%= t("pages.features.flow_phone") %></span>
|
||||||
<h3><%= t("pages.features.staff_invite_title") %></h3>
|
</li>
|
||||||
<p><%= t("pages.features.staff_invite_body") %></p>
|
<li class="features-flow__arrow" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></li>
|
||||||
|
<li class="features-flow__item">
|
||||||
|
<span class="features-flow__icon features-flow__icon--blue" aria-hidden="true"><i class="fa-solid fa-user"></i></span>
|
||||||
|
<span class="features-flow__label"><%= t("pages.features.flow_account") %></span>
|
||||||
|
</li>
|
||||||
|
<li class="features-flow__arrow" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></li>
|
||||||
|
<li class="features-flow__item features-flow__item--live">
|
||||||
|
<span class="features-flow__icon features-flow__icon--live" aria-hidden="true">
|
||||||
|
<span class="features-flow__dot"></span>
|
||||||
|
</span>
|
||||||
|
<span class="features-flow__label"><%= t("pages.features.flow_live") %></span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="features-hero__visual">
|
||||||
<h3><%= t("pages.features.staff_access_title") %></h3>
|
<%= image_tag "/hero-devices.png",
|
||||||
<p><%= t("pages.features.staff_access_body") %></p>
|
alt: t("pages.features.hero_alt"),
|
||||||
|
class: "features-hero__img",
|
||||||
|
loading: "eager",
|
||||||
|
fetchpriority: "high" %>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
</div>
|
||||||
<h3><%= t("pages.features.staff_plans_title") %></h3>
|
</header>
|
||||||
<p><%= t("pages.features.staff_plans_body") %></p>
|
|
||||||
|
<section class="section wrap features-how" aria-labelledby="features-how-title">
|
||||||
|
<h2 id="features-how-title"><%= t("pages.features.how_title") %></h2>
|
||||||
|
<ol class="features-how__steps">
|
||||||
|
<li class="features-how__step">
|
||||||
|
<span class="features-how__num" aria-hidden="true">1</span>
|
||||||
|
<h3><%= t("pages.features.how_1_title") %></h3>
|
||||||
|
<p><%= t("pages.features.how_1_body") %></p>
|
||||||
|
</li>
|
||||||
|
<li class="features-how__step">
|
||||||
|
<span class="features-how__num" aria-hidden="true">2</span>
|
||||||
|
<h3><%= t("pages.features.how_2_title") %></h3>
|
||||||
|
<p><%= t("pages.features.how_2_body") %></p>
|
||||||
|
</li>
|
||||||
|
<li class="features-how__step">
|
||||||
|
<span class="features-how__num" aria-hidden="true">3</span>
|
||||||
|
<h3><%= t("pages.features.how_3_title") %></h3>
|
||||||
|
<p><%= t("pages.features.how_3_body") %></p>
|
||||||
|
</li>
|
||||||
|
<li class="features-how__step">
|
||||||
|
<span class="features-how__num" aria-hidden="true">4</span>
|
||||||
|
<h3><%= t("pages.features.how_4_title") %></h3>
|
||||||
|
<p><%= t("pages.features.how_4_body") %></p>
|
||||||
|
</li>
|
||||||
|
<li class="features-how__step">
|
||||||
|
<span class="features-how__num" aria-hidden="true">5</span>
|
||||||
|
<h3><%= t("pages.features.how_5_title") %></h3>
|
||||||
|
<p><%= t("pages.features.how_5_body") %></p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap features-accounts" aria-labelledby="features-accounts-title">
|
||||||
|
<div class="features-panel">
|
||||||
|
<h2 id="features-accounts-title"><%= t("pages.features.accounts_title") %></h2>
|
||||||
|
<p class="features-panel__lead"><%= t("pages.features.accounts_lead") %></p>
|
||||||
|
<div class="features-accounts__grid">
|
||||||
|
<div class="features-accounts__card">
|
||||||
|
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-user-plus"></i></span>
|
||||||
|
<h3><%= t("pages.features.accounts_invite_title") %></h3>
|
||||||
|
<p><%= t("pages.features.accounts_invite_body") %></p>
|
||||||
|
</div>
|
||||||
|
<div class="features-accounts__card">
|
||||||
|
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
||||||
|
<h3><%= t("pages.features.accounts_transmit_title") %></h3>
|
||||||
|
<p><%= t("pages.features.accounts_transmit_body") %></p>
|
||||||
|
</div>
|
||||||
|
<div class="features-accounts__card">
|
||||||
|
<span class="feature-card__icon feature-card__icon--gold" aria-hidden="true"><i class="fa-solid fa-user-shield"></i></span>
|
||||||
|
<h3><%= t("pages.features.accounts_revoke_title") %></h3>
|
||||||
|
<p><%= t("pages.features.accounts_revoke_body") %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="features-highlight"><%= t("pages.features.accounts_highlight") %></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap features-score" aria-labelledby="features-score-title">
|
||||||
|
<div class="features-split">
|
||||||
|
<div class="features-split__copy">
|
||||||
|
<h2 id="features-score-title"><%= t("pages.features.score_title") %></h2>
|
||||||
|
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.score_lead") %></p>
|
||||||
|
<ul class="features-checklist">
|
||||||
|
<li><%= t("pages.features.score_item_app") %></li>
|
||||||
|
<li><%= t("pages.features.score_item_signup") %></li>
|
||||||
|
<li><%= t("pages.features.score_item_password") %></li>
|
||||||
|
<li><%= t("pages.features.score_item_link") %></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<aside class="features-score__card" aria-label="<%= t("pages.features.score_card_aria") %>">
|
||||||
|
<div class="features-score__visual" aria-hidden="true">
|
||||||
|
<div class="features-score__phone">
|
||||||
|
<div class="features-score__phone-top">
|
||||||
|
<span class="features-score__live"><span class="features-score__live-dot"></span> REGIA</span>
|
||||||
|
<span class="features-score__set">3° set</span>
|
||||||
|
</div>
|
||||||
|
<div class="features-score__board">
|
||||||
|
<div class="features-score__team">
|
||||||
|
<span class="features-score__team-name">Casa</span>
|
||||||
|
<span class="features-score__points">21</span>
|
||||||
|
</div>
|
||||||
|
<span class="features-score__sep">–</span>
|
||||||
|
<div class="features-score__team features-score__team--away">
|
||||||
|
<span class="features-score__team-name">Ospiti</span>
|
||||||
|
<span class="features-score__points">18</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="features-score__actions">
|
||||||
|
<span class="features-score__btn">+1</span>
|
||||||
|
<span class="features-score__btn features-score__btn--muted">+1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="features-score__linkchip">
|
||||||
|
<i class="fa-solid fa-link" aria-hidden="true"></i>
|
||||||
|
<span>matchlivetv.it/regia/…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="features-score__body">
|
||||||
|
<span class="feature-card__badge feature-card__badge--link"><%= t("pages.features.access_link") %></span>
|
||||||
|
<p class="features-score__card-title"><%= t("pages.features.score_card_title") %></p>
|
||||||
|
<p><%= t("pages.features.score_card_body") %></p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap features-distribute" aria-labelledby="features-distribute-title">
|
||||||
|
<h2 id="features-distribute-title"><%= t("pages.features.distribute_title") %></h2>
|
||||||
|
<p class="features-panel__lead"><%= t("pages.features.distribute_lead") %></p>
|
||||||
|
<ol class="features-pipeline" aria-label="<%= t("pages.features.distribute_aria") %>">
|
||||||
|
<li class="features-pipeline__node">
|
||||||
|
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
||||||
|
<span class="features-pipeline__label"><%= t("pages.features.distribute_phone") %></span>
|
||||||
|
</li>
|
||||||
|
<li class="features-pipeline__join" aria-hidden="true"></li>
|
||||||
|
<li class="features-pipeline__node features-pipeline__node--hub">
|
||||||
|
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||||
|
<span class="features-pipeline__label"><%= t("pages.features.distribute_mltv") %></span>
|
||||||
|
</li>
|
||||||
|
<li class="features-pipeline__join" aria-hidden="true"></li>
|
||||||
|
<li class="features-pipeline__targets">
|
||||||
|
<div class="features-pipeline__node">
|
||||||
|
<span class="features-pipeline__icon features-pipeline__icon--blue" aria-hidden="true"><i class="fa-solid fa-users"></i></span>
|
||||||
|
<span class="features-pipeline__label"><%= t("pages.features.distribute_viewers") %></span>
|
||||||
|
</div>
|
||||||
|
<div class="features-pipeline__node">
|
||||||
|
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
|
||||||
|
<span class="features-pipeline__label"><%= t("pages.features.distribute_youtube") %></span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap features-youtube" aria-labelledby="features-youtube-title">
|
||||||
|
<div class="features-panel features-panel--accent features-youtube__panel">
|
||||||
|
<div class="features-youtube__copy">
|
||||||
|
<span class="feature-card__badge feature-card__badge--invite"><%= t("pages.features.youtube_badge") %></span>
|
||||||
|
<h2 id="features-youtube-title"><%= t("pages.features.youtube_title") %></h2>
|
||||||
|
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.youtube_body") %></p>
|
||||||
|
</div>
|
||||||
|
<div class="features-youtube__visual" aria-hidden="true">
|
||||||
|
<div class="features-yt-mock">
|
||||||
|
<div class="features-yt-mock__player">
|
||||||
|
<span class="features-yt-mock__live"><span class="features-yt-mock__dot"></span> LIVE</span>
|
||||||
|
<span class="features-yt-mock__play"><i class="fa-brands fa-youtube"></i></span>
|
||||||
|
</div>
|
||||||
|
<div class="features-yt-mock__meta">
|
||||||
|
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
|
||||||
|
<div class="features-yt-mock__text">
|
||||||
|
<strong><%= t("pages.features.youtube_mock_channel") %></strong>
|
||||||
|
<span><%= t("pages.features.youtube_mock_subs") %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
|
||||||
<h2><%= t("pages.features.live_section_title") %></h2>
|
<div class="features-split features-split--reverse">
|
||||||
<div class="features-grid">
|
<div class="features-split__copy">
|
||||||
|
<h2 id="features-replay-title"><%= t("pages.features.replay_title") %></h2>
|
||||||
|
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.replay_lead") %></p>
|
||||||
|
<ul class="features-checklist">
|
||||||
|
<li><%= t("pages.features.replay_item_server") %></li>
|
||||||
|
<li><%= t("pages.features.replay_item_plan") %></li>
|
||||||
|
<li><%= t("pages.features.replay_item_download") %></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<aside class="features-replay__card" aria-label="<%= t("pages.features.replay_card_title") %>">
|
||||||
|
<div class="features-replay__visual" aria-hidden="true">
|
||||||
|
<div class="features-archive-mock">
|
||||||
|
<div class="features-archive-mock__head">
|
||||||
|
<i class="fa-solid fa-cloud" aria-hidden="true"></i>
|
||||||
|
<span><%= t("pages.features.replay_card_title") %></span>
|
||||||
|
</div>
|
||||||
|
<ul class="features-archive-mock__list">
|
||||||
|
<li>
|
||||||
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
|
||||||
|
<span class="features-archive-mock__info">
|
||||||
|
<strong><%= t("pages.features.replay_mock_1_title") %></strong>
|
||||||
|
<em><%= t("pages.features.replay_mock_1_meta") %></em>
|
||||||
|
</span>
|
||||||
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
|
||||||
|
<span class="features-archive-mock__info">
|
||||||
|
<strong><%= t("pages.features.replay_mock_2_title") %></strong>
|
||||||
|
<em><%= t("pages.features.replay_mock_2_meta") %></em>
|
||||||
|
</span>
|
||||||
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
|
||||||
|
<span class="features-archive-mock__info">
|
||||||
|
<strong><%= t("pages.features.replay_mock_3_title") %></strong>
|
||||||
|
<em><%= t("pages.features.replay_mock_3_meta") %></em>
|
||||||
|
</span>
|
||||||
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="features-replay__body">
|
||||||
|
<p class="features-score__card-title"><%= t("pages.features.replay_card_title") %></p>
|
||||||
|
<p><%= t("pages.features.replay_card_body") %></p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap features-more" aria-labelledby="features-more-title">
|
||||||
|
<h2 id="features-more-title"><%= t("pages.features.more_title") %></h2>
|
||||||
|
<div class="features-grid features-grid--compact">
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<h3><%= t("pages.features.live_phone_title") %></h3>
|
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||||
<p><%= t("pages.features.live_phone_body") %></p>
|
<h3><%= t("pages.features.more_stable_title") %></h3>
|
||||||
|
<p><%= t("pages.features.more_stable_body") %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<h3><%= t("pages.features.live_score_title") %></h3>
|
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-share-nodes"></i></span>
|
||||||
<p><%= t("pages.features.live_score_body") %></p>
|
<h3><%= t("pages.features.more_link_title") %></h3>
|
||||||
|
<p><%= t("pages.features.more_link_body") %></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-card">
|
<div class="feature-card">
|
||||||
<h3><%= t("pages.features.live_link_title") %></h3>
|
<span class="feature-card__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
|
||||||
<p><%= t("pages.features.live_link_body") %></p>
|
<h3><%= t("pages.features.more_yt_mltv_title") %></h3>
|
||||||
</div>
|
<p><%= t("pages.features.more_yt_mltv_body") %></p>
|
||||||
<div class="feature-card">
|
|
||||||
<h3><%= t("pages.features.live_archive_title") %></h3>
|
|
||||||
<p><%= t("pages.features.live_archive_body") %></p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<h3><%= t("pages.features.live_youtube_mltv_title") %></h3>
|
|
||||||
<p><%= t("pages.features.live_youtube_mltv_body") %></p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<h3><%= t("pages.features.live_youtube_club_title") %></h3>
|
|
||||||
<p><%= t("pages.features.live_youtube_club_body") %></p>
|
|
||||||
</div>
|
|
||||||
<div class="feature-card">
|
|
||||||
<h3><%= t("pages.features.live_website_title") %></h3>
|
|
||||||
<p><%= t("pages.features.live_website_body") %></p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p style="text-align:center;margin:40px 0">
|
<section class="section wrap features-cta" aria-labelledby="features-cta-title">
|
||||||
<%= link_to t("pages.features.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
<div class="features-cta__box">
|
||||||
</p>
|
<h2 id="features-cta-title"><%= t("pages.features.cta_title") %></h2>
|
||||||
</div>
|
<p><%= t("pages.features.cta_body") %></p>
|
||||||
|
<div class="features-cta__actions">
|
||||||
|
<%= link_to t("pages.features.cta_primary"), public_signup_path, class: "btn btn-primary" %>
|
||||||
|
<%= link_to t("pages.features.cta_secondary"), public_prezzi_path, class: "btn btn-outline" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"url" => seo_absolute_url(root_path),
|
"url" => seo_absolute_url(root_path),
|
||||||
"applicationCategory" => "SportsApplication",
|
"applicationCategory" => "SportsApplication",
|
||||||
"operatingSystem" => "Web, Android, iOS",
|
"operatingSystem" => "Web, Android, iOS",
|
||||||
"description" => t("home.schema_description")
|
"description" => t("home.schema_description"),
|
||||||
|
"sameAs" => [MatchLiveTv.app_store_url, MatchLiveTv.play_store_url]
|
||||||
}.to_json) %>
|
}.to_json) %>
|
||||||
</script>
|
</script>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -26,6 +27,10 @@
|
|||||||
<%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
<%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||||
<%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %>
|
<%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hero-stores">
|
||||||
|
<p class="hero-stores-label"><%= t("home.stores_label") %></p>
|
||||||
|
<%= render "shared/store_badges", variant: "hero" %>
|
||||||
|
</div>
|
||||||
<ul class="hero-features" aria-label="<%= t('home.features_aria') %>">
|
<ul class="hero-features" aria-label="<%= t('home.features_aria') %>">
|
||||||
<li class="hero-feature-item">
|
<li class="hero-feature-item">
|
||||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||||
|
|||||||
@@ -22,19 +22,60 @@
|
|||||||
|
|
||||||
<%= render "shared/plan_cards" %>
|
<%= render "shared/plan_cards" %>
|
||||||
|
|
||||||
<div class="table-scroll">
|
<div class="table-scroll compare-table-wrap">
|
||||||
<table class="compare-table">
|
<table class="compare-table">
|
||||||
|
<colgroup>
|
||||||
|
<col class="compare-table__feature">
|
||||||
|
<col class="compare-table__plan">
|
||||||
|
<col class="compare-table__plan">
|
||||||
|
<col class="compare-table__plan compare-table__plan--featured">
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Free</th>
|
||||||
|
<th>Premium Light</th>
|
||||||
|
<th>Premium Full</th>
|
||||||
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></tr>
|
<tr>
|
||||||
|
<td><%= t("pages.pricing.table_staff") %></td>
|
||||||
|
<td>1</td>
|
||||||
|
<td>5</td>
|
||||||
|
<td><%= t("pages.pricing.table_unlimited") %></td>
|
||||||
|
</tr>
|
||||||
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr>
|
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr>
|
||||||
<tr><td><%= t("pages.pricing.table_live_mltv") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
<tr><td><%= t("pages.pricing.table_live_mltv") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||||
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
|
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
|
||||||
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
|
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
|
||||||
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||||
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
|
<tr>
|
||||||
|
<td><%= t("pages.pricing.table_price") %></td>
|
||||||
|
<td><%= t("pages.pricing.table_price_free") %></td>
|
||||||
|
<td>
|
||||||
|
<div class="compare-price">
|
||||||
|
<strong><%= Billing::Stripe::PriceCatalog.label(plan_slug: "premium_light", interval: "yearly") %></strong>
|
||||||
|
<span class="compare-price__launch"><%= t("pages.plans.launch_price") %></span>
|
||||||
|
<small class="compare-price__note"><%= t(
|
||||||
|
"pages.pricing.table_price_note",
|
||||||
|
list: Billing::Stripe::PriceCatalog.list_label(plan_slug: "premium_light", interval: "yearly"),
|
||||||
|
monthly: Billing::Stripe::PriceCatalog.label(plan_slug: "premium_light", interval: "monthly")
|
||||||
|
) %></small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="compare-price">
|
||||||
|
<strong><%= Billing::Stripe::PriceCatalog.label(plan_slug: "premium_full", interval: "yearly") %></strong>
|
||||||
|
<span class="compare-price__launch"><%= t("pages.plans.launch_price") %></span>
|
||||||
|
<small class="compare-price__note"><%= t(
|
||||||
|
"pages.pricing.table_price_note",
|
||||||
|
list: Billing::Stripe::PriceCatalog.list_label(plan_slug: "premium_full", interval: "yearly"),
|
||||||
|
monthly: Billing::Stripe::PriceCatalog.label(plan_slug: "premium_full", interval: "monthly")
|
||||||
|
) %></small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
||||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||||
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
data-resume-url="<%= j public_regia_resume_path(params[:token]) %>"
|
||||||
|
data-audio-mute-url="<%= j public_regia_audio_mute_path(params[:token]) %>"
|
||||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||||
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
|
||||||
data-min-quality="<%= j @session.min_quality_preset %>"
|
data-min-quality="<%= j @session.min_quality_preset %>"
|
||||||
@@ -28,7 +29,8 @@
|
|||||||
data-live-share-title="<%= j @live_share_title %>"
|
data-live-share-title="<%= j @live_share_title %>"
|
||||||
data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>"
|
data-points-target="<%= @match.effective_board_type.in?(%w[volley racket]) ? Scoring::Rules.from_match(@match).points_target(@score.current_set) : 0 %>"
|
||||||
data-stream-closed="<%= @stream_closed %>"
|
data-stream-closed="<%= @stream_closed %>"
|
||||||
data-initial-paused="<%= @session.paused? %>">
|
data-initial-paused="<%= @session.paused? %>"
|
||||||
|
data-initial-audio-muted="<%= @session.audio_muted %>">
|
||||||
|
|
||||||
<header class="regia-header">
|
<header class="regia-header">
|
||||||
<div class="regia-header__text">
|
<div class="regia-header__text">
|
||||||
@@ -77,6 +79,10 @@
|
|||||||
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
||||||
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="regia-icon-btn<%= " is-muted" if @session.audio_muted %>" id="btn-audio-mute" aria-label="<%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %>" title="<%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %>">
|
||||||
|
<svg class="icon-fill icon-mic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5-3c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg>
|
||||||
|
<svg class="icon-fill icon-mic-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 11h-1.7c0 .74-.16 1.43-.43 2.05l1.23 1.23c.56-.98.9-2.09.9-3.28zm-4.02.17c0-.06.02-.11.02-.17V5c0-1.66-1.34-3-3-3S9 3.34 9 5v.18l5.98 5.99zM4.27 3L3 4.27l6.01 6.01V11c0 1.66 1.33 3 2.99 3 .22 0 .44-.03.65-.08l1.66 1.66c-.71.33-1.5.52-2.31.52-2.76 0-5.3-2.1-5.3-5.1H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c.91-.13 1.77-.45 2.54-.9L19.73 21 21 19.73 4.27 3z"/></svg>
|
||||||
|
</button>
|
||||||
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
|
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
|
||||||
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
|
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -159,4 +165,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=18"></script>
|
<script src="/regia.js?v=19"></script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<% recordings = local_assigns[:recordings] %>
|
<% recordings = local_assigns[:recordings] %>
|
||||||
<% entitlements = local_assigns[:entitlements] %>
|
<% entitlements = local_assigns[:entitlements] %>
|
||||||
|
|
||||||
<section class="team-streaming-staff">
|
<section class="team-streaming-staff" id="responsabili-trasmissione">
|
||||||
<% if can_manage %>
|
<% if can_manage %>
|
||||||
<% if owner_membership&.staff_kind.blank? %>
|
<% if owner_membership&.staff_kind.blank? %>
|
||||||
<div class="card team-streaming-staff__self">
|
<div class="card team-streaming-staff__self">
|
||||||
@@ -51,12 +51,34 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
<%= t("team.streaming_staff.no_staff") %>
|
<%= t("team.streaming_staff.no_staff") %>
|
||||||
<% if can_manage %><%= link_to t("team.streaming_staff.invite_someone"), public_team_invite_path(team) %>.<% end %>
|
<% if can_manage %><%= link_to t("team.streaming_staff.invite_someone"), "#invita-trasmissione" %>.<% end %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if can_manage %>
|
<% if can_manage %>
|
||||||
|
<div class="card team-invite-form" id="invita-trasmissione">
|
||||||
|
<h2><%= t("team.streaming_staff.invite_form_heading") %></h2>
|
||||||
|
<p class="muted team-invite-form__hint"><%= t("team.streaming_staff.invite_form_hint") %></p>
|
||||||
|
<%= form_with url: public_team_invite_path(team), method: :post, class: "team-invite-form__fields" do %>
|
||||||
|
<%= hidden_field_tag :staff_kind, "transmission" %>
|
||||||
|
<%= label_tag :email, t("team.invite.email_label") %>
|
||||||
|
<%= email_field_tag :email, params[:email], required: true, autocomplete: "email", placeholder: "nome@email.it" %>
|
||||||
|
<%= submit_tag t("team.invite.submit"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if flash[:invite_url].present? %>
|
||||||
|
<div class="team-invite-link" id="invito-generato">
|
||||||
|
<p class="team-invite-link__title"><%= t("team.invite.share_hint") %></p>
|
||||||
|
<code class="team-invite-link__url" id="team-invite-url"><%= flash[:invite_url] %></code>
|
||||||
|
<button type="button" class="btn btn-secondary team-invite-link__copy" data-copy-target="team-invite-url">
|
||||||
|
<%= t("regia.copy_link") %>
|
||||||
|
</button>
|
||||||
|
<p class="muted team-invite-link__note"><%= t("team.streaming_staff.invite_once_note") %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2><%= t("team.streaming_staff.pending_invitations_heading") %></h2>
|
<h2><%= t("team.streaming_staff.pending_invitations_heading") %></h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<% if pending_invitations.any? %>
|
<% if pending_invitations.any? %>
|
||||||
@@ -114,3 +136,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.querySelectorAll("[data-copy-target]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", async function () {
|
||||||
|
var el = document.getElementById(btn.getAttribute("data-copy-target"));
|
||||||
|
if (!el) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(el.textContent.trim());
|
||||||
|
btn.textContent = <%= raw t("team.streaming_staff.copied").to_json %>;
|
||||||
|
} catch (e) {
|
||||||
|
window.getSelection().selectAllChildren(el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<% if @can_manage %>
|
<% if @can_manage %>
|
||||||
<%= link_to t("club.back_to_club"), public_club_path(@club), class: "btn btn-secondary" %>
|
<%= link_to t("club.back_to_club"), public_club_path(@club), class: "btn btn-secondary" %>
|
||||||
<%= link_to t("team.details.public_page"), public_team_page_path(@team.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %>
|
<%= link_to t("team.details.public_page"), public_team_page_path(@team.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %>
|
||||||
<%= link_to t("team.details.streaming_managers"), public_team_invite_path(@team), class: "btn btn-secondary" %>
|
<%= link_to t("team.details.streaming_managers"), "#responsabili-trasmissione", class: "btn btn-secondary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if current_user.can_stream_for?(@team) %>
|
<% if current_user.can_stream_for?(@team) %>
|
||||||
<%= link_to t("team.details.schedule_matches"), public_team_matches_path(@team), class: "btn btn-primary" %>
|
<%= link_to t("team.details.schedule_matches"), public_team_matches_path(@team), class: "btn btn-primary" %>
|
||||||
|
|||||||
@@ -1,38 +1,8 @@
|
|||||||
|
<%# Pagina legacy: l'invito è sulla pagina dettagli squadra. %>
|
||||||
<% content_for :title, t("team.invite.title", name: @team.name) %>
|
<% content_for :title, t("team.invite.title", name: @team.name) %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="wrap" style="padding-top:20px">
|
<div class="wrap" style="padding-top:20px">
|
||||||
<h1><%= t("team.invite.heading") %></h1>
|
<p class="muted"><%= t("team.streaming_staff.invite_form_hint") %></p>
|
||||||
<p>
|
<p><%= link_to t("team.back_to_details"), public_team_details_path(@team, anchor: "invita-trasmissione"), class: "btn btn-primary" %></p>
|
||||||
<%= t("team.invite.assigned_label") %> <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
|
||||||
<%= t("team.invite.per_team_note") %>
|
|
||||||
</p>
|
|
||||||
<p class="muted" style="max-width:640px">
|
|
||||||
<%= raw t("team.invite.info") %>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<% owner_membership = current_user.user_teams.find_by(team: @team) %>
|
|
||||||
<% if owner_membership&.staff_kind.blank? %>
|
|
||||||
<div class="card" style="max-width:520px;margin-bottom:16px">
|
|
||||||
<p style="margin:0 0 12px;color:#aaa;font-size:0.92rem">
|
|
||||||
<%= raw t("team.invite.self_assign_hint_html", email: current_user.email) %>
|
|
||||||
</p>
|
|
||||||
<%= button_to t("team.invite.self_assign_submit"), public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="card" style="max-width:520px">
|
|
||||||
<h2 style="margin-top:0;font-size:1.05rem"><%= t("team.invite.invite_other_heading") %></h2>
|
|
||||||
<%= form_with url: public_team_invite_path(@team), method: :post do %>
|
|
||||||
<%= hidden_field_tag :staff_kind, "transmission" %>
|
|
||||||
<%= label_tag :email, t("team.invite.email_label") %>
|
|
||||||
<%= email_field_tag :email, params[:email], required: true %>
|
|
||||||
<%= submit_tag t("team.invite.submit"), class: "btn btn-primary" %>
|
|
||||||
<% end %>
|
|
||||||
<% if defined?(@invite_url) && @invite_url.present? %>
|
|
||||||
<p style="margin-top:16px"><%= t("team.invite.share_hint") %></p>
|
|
||||||
<code style="word-break:break-all;display:block;background:#0a0a0e;padding:12px;border-radius:8px"><%= @invite_url %></code>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<p><%= link_to t("team.back_to_details"), public_team_details_path(@team) %></p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div>
|
<div>
|
||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
|
<%= render "shared/store_badges", variant: "footer" %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<%= link_to t("common.support"), public_support_path %> ·
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
|
|||||||
@@ -15,23 +15,33 @@
|
|||||||
current_interval: current_interval,
|
current_interval: current_interval,
|
||||||
subscription: subscription
|
subscription: subscription
|
||||||
) : nil %>
|
) : nil %>
|
||||||
<div class="plan-card">
|
<% featured = plan.slug == "premium_full" %>
|
||||||
|
<div class="plan-card<%= " plan-card--featured" if featured %>">
|
||||||
|
<% if featured %>
|
||||||
|
<p class="plan-card__recommend"><%= t("pages.plans.recommended_badge") %></p>
|
||||||
|
<% end %>
|
||||||
<%= render "shared/plan_title", plan: plan %>
|
<%= render "shared/plan_title", plan: plan %>
|
||||||
<% if plan.slug == "free" %>
|
<% if plan.slug == "free" %>
|
||||||
<div class="plan-price">€0</div>
|
<div class="plan-price">€0</div>
|
||||||
<% elsif plan.slug == "premium_light" %>
|
|
||||||
<% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
|
||||||
<% light_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
|
||||||
<div class="plan-price" style="color:#e53935"><%= light_yearly %></div>
|
|
||||||
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: light_monthly) %></p>
|
|
||||||
<% else %>
|
<% else %>
|
||||||
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
<% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||||
<% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
<% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||||
<div class="plan-price"><%= full_yearly %></div>
|
<% list = Billing::Stripe::PriceCatalog.list_label(plan_slug: plan.slug, interval: "yearly") %>
|
||||||
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: full_monthly) %></p>
|
<% equiv = Billing::Stripe::PriceCatalog.equivalent_monthly_label(plan_slug: plan.slug) %>
|
||||||
|
<div class="plan-price-block">
|
||||||
|
<% if list.present? %>
|
||||||
|
<div class="plan-price-list"><s><%= list %></s></div>
|
||||||
|
<% end %>
|
||||||
|
<div class="plan-price<%= " plan-price--featured" if featured %>"><%= yearly %></div>
|
||||||
|
<span class="plan-price-badge"><%= t("pages.plans.launch_price") %></span>
|
||||||
|
<% if equiv.present? %>
|
||||||
|
<p class="plan-price-equiv"><%= t("pages.plans.price_equiv", price: equiv) %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: monthly) %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<ul>
|
<ul>
|
||||||
<li><%= raw t("pages.plans.staff_html", count: plan.max_staff_transmission || t("pages.plans.staff_unlimited")) %></li>
|
<li><%= raw(plan.max_staff_transmission ? t("pages.plans.staff_html", count: plan.max_staff_transmission) : t("pages.plans.staff_unlimited_html")) %></li>
|
||||||
<li><%= raw t("pages.plans.matches_html", count: plan.concurrent_streams_limit || t("pages.plans.matches_unlimited")) %></li>
|
<li><%= raw t("pages.plans.matches_html", count: plan.concurrent_streams_limit || t("pages.plans.matches_unlimited")) %></li>
|
||||||
<li><%= raw t(
|
<li><%= raw t(
|
||||||
"pages.plans.replay_html",
|
"pages.plans.replay_html",
|
||||||
@@ -48,6 +58,9 @@
|
|||||||
end
|
end
|
||||||
) %></li>
|
) %></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<% if plan.slug == "premium_full" %>
|
||||||
|
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
|
||||||
|
<% end %>
|
||||||
<% if billing_mode %>
|
<% if billing_mode %>
|
||||||
<% action_kind = action[:kind] %>
|
<% action_kind = action[:kind] %>
|
||||||
<% if action_kind == :current %>
|
<% if action_kind == :current %>
|
||||||
@@ -78,8 +91,12 @@
|
|||||||
<%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %>
|
<%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="plan-interval-actions">
|
<div class="plan-interval-actions">
|
||||||
<% plan_intervals_for_display(Billing::Stripe::PriceCatalog.available_intervals(plan_slug: plan.slug)).each do |interval| %>
|
<% %w[yearly monthly].each do |interval| %>
|
||||||
<%= link_to t("pages.plans.register_with_price", price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)),
|
<%= link_to t(
|
||||||
|
"billing.messages.activate_button",
|
||||||
|
plan: plan_cta_name(plan),
|
||||||
|
price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
||||||
|
),
|
||||||
public_signup_path(plan: plan.slug, interval: interval),
|
public_signup_path(plan: plan.slug, interval: interval),
|
||||||
class: plan_interval_button_class(interval) %>
|
class: plan_interval_button_class(interval) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -88,4 +105,3 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<% variant = local_assigns.fetch(:variant, "hero") %>
|
||||||
|
<div class="store-badges store-badges--<%= variant %>">
|
||||||
|
<%= link_to MatchLiveTv.app_store_url,
|
||||||
|
class: "store-badge",
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener noreferrer",
|
||||||
|
aria: { label: t("home.store_app_store_aria") } do %>
|
||||||
|
<%= image_tag "/images/store/apple.svg",
|
||||||
|
alt: "",
|
||||||
|
class: "store-badge__logo store-badge__logo--apple",
|
||||||
|
aria: { hidden: true } %>
|
||||||
|
<span>
|
||||||
|
<span class="store-badge__kicker"><%= t("home.store_app_store_kicker") %></span>
|
||||||
|
<span class="store-badge__name"><%= t("home.store_app_store_name") %></span>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<%= link_to MatchLiveTv.play_store_url,
|
||||||
|
class: "store-badge",
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener noreferrer",
|
||||||
|
aria: { label: t("home.store_play_aria") } do %>
|
||||||
|
<%= image_tag "/images/store/google-play.svg",
|
||||||
|
alt: "",
|
||||||
|
class: "store-badge__logo store-badge__logo--play",
|
||||||
|
aria: { hidden: true } %>
|
||||||
|
<span>
|
||||||
|
<span class="store-badge__kicker"><%= t("home.store_play_kicker") %></span>
|
||||||
|
<span class="store-badge__name"><%= t("home.store_play_name") %></span>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 16px; line-height: 1.5; color: #1a1a1a; max-width: 560px;">
|
||||||
|
<p style="margin: 0 0 16px;"><%= t("mailers.transmission_invite.hello") %></p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 16px;">
|
||||||
|
<%= raw t(
|
||||||
|
"mailers.transmission_invite.body_html",
|
||||||
|
inviter: @invited_by.name,
|
||||||
|
team: @team.name,
|
||||||
|
club: @club.name
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 16px;">
|
||||||
|
<a href="<%= @invite_url %>" style="display: inline-block; background: #e53935; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; font-weight: 600;">
|
||||||
|
<%= t("mailers.transmission_invite.cta") %>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 16px; color: #444; font-size: 14px;">
|
||||||
|
<%= t("mailers.transmission_invite.link_fallback") %><br>
|
||||||
|
<a href="<%= @invite_url %>" style="color: #e53935; word-break: break-all;"><%= @invite_url %></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 16px;"><%= t("mailers.transmission_invite.steps") %></p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 16px;"><%= t("mailers.transmission_invite.expiry", date: @expires_on) %></p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 24px; color: #666;"><%= t("mailers.transmission_invite.ignore") %></p>
|
||||||
|
|
||||||
|
<p style="margin: 0; color: #888; font-size: 12px;"><%= t("mailers.transmission_invite.footer", email: MatchLiveTv.privacy_controller_email) %></p>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<%= t("mailers.transmission_invite.hello") %>
|
||||||
|
|
||||||
|
<%= t(
|
||||||
|
"mailers.transmission_invite.body_text",
|
||||||
|
inviter: @invited_by.name,
|
||||||
|
team: @team.name,
|
||||||
|
club: @club.name
|
||||||
|
) %>
|
||||||
|
|
||||||
|
<%= t("mailers.transmission_invite.link_text") %>
|
||||||
|
<%= @invite_url %>
|
||||||
|
|
||||||
|
<%= t("mailers.transmission_invite.steps") %>
|
||||||
|
|
||||||
|
<%= t("mailers.transmission_invite.expiry", date: @expires_on) %>
|
||||||
|
|
||||||
|
<%= t("mailers.transmission_invite.ignore") %>
|
||||||
|
|
||||||
|
<%= t("mailers.transmission_invite.footer", email: MatchLiveTv.privacy_controller_email) %>
|
||||||
@@ -180,5 +180,13 @@ module MatchLiveTv
|
|||||||
def google_analytics_configured?
|
def google_analytics_configured?
|
||||||
google_analytics_measurement_id.present?
|
google_analytics_measurement_id.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def app_store_url
|
||||||
|
"https://apps.apple.com/us/app/matchlivetv/id6799510705"
|
||||||
|
end
|
||||||
|
|
||||||
|
def play_store_url
|
||||||
|
"https://play.google.com/store/apps/details?id=com.matchlivetv.match_live_tv"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Sidekiq.configure_server do |config|
|
|||||||
config.on(:startup) do
|
config.on(:startup) do
|
||||||
StreamPublisherSyncJob.ensure_chain
|
StreamPublisherSyncJob.ensure_chain
|
||||||
Ops::HealthMonitorJob.ensure_chain
|
Ops::HealthMonitorJob.ensure_chain
|
||||||
|
Streams::AutoscalerJob.ensure_chain
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ de:
|
|||||||
billing: Abrechnung
|
billing: Abrechnung
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sitzungen
|
sessions: Sitzungen
|
||||||
|
stream_nodes: Stream-Knoten
|
||||||
password: Passwort
|
password: Passwort
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
flash:
|
flash:
|
||||||
@@ -26,6 +27,11 @@ de:
|
|||||||
ops_acknowledged: Vorfall übernommen
|
ops_acknowledged: Vorfall übernommen
|
||||||
ops_resolved: Vorfall gelöst
|
ops_resolved: Vorfall gelöst
|
||||||
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
|
ops_muted: Benachrichtigungen für 24 Stunden stummgeschaltet
|
||||||
|
stream_node_created: "Lab-Knoten %{slug} bereitgestellt."
|
||||||
|
stream_node_destroyed: "Knoten %{slug} entfernt."
|
||||||
|
stream_node_draining: "Knoten %{slug} im Drain-Modus."
|
||||||
|
autoscale_kill_on: "Autoscaler-Kill-Switch aktiv. Kein automatisches Scale-out."
|
||||||
|
autoscale_kill_off: "Autoscaler-Kill-Switch aus (STREAM_AUTOSCALE_ENABLED=1 weiterhin nötig)."
|
||||||
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
|
comped_granted: "Kostenloses Abonnement %{plan} für %{club} aktiviert."
|
||||||
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
comped_revoked: "Kostenloses Abonnement für %{club} widerrufen."
|
||||||
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
session_already_terminated: "Sitzung bereits beendet (%{status})."
|
||||||
@@ -111,6 +117,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:
|
||||||
@@ -26,6 +27,11 @@ en:
|
|||||||
ops_acknowledged: Incident acknowledged
|
ops_acknowledged: Incident acknowledged
|
||||||
ops_resolved: Incident resolved
|
ops_resolved: Incident resolved
|
||||||
ops_muted: Notifications muted for 24 hours
|
ops_muted: Notifications muted for 24 hours
|
||||||
|
stream_node_created: "Lab node %{slug} provisioned."
|
||||||
|
stream_node_destroyed: "Node %{slug} removed."
|
||||||
|
stream_node_draining: "Node %{slug} is draining (no new sessions)."
|
||||||
|
autoscale_kill_on: "Autoscaler kill-switch engaged. No automatic scale-out."
|
||||||
|
autoscale_kill_off: "Autoscaler kill-switch cleared (still needs STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "%{plan} complimentary subscription activated for %{club}."
|
comped_granted: "%{plan} complimentary subscription activated for %{club}."
|
||||||
comped_revoked: "Complimentary subscription revoked for %{club}."
|
comped_revoked: "Complimentary subscription revoked for %{club}."
|
||||||
session_already_terminated: "Session already ended (%{status})."
|
session_already_terminated: "Session already ended (%{status})."
|
||||||
@@ -111,6 +117,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:
|
||||||
@@ -26,6 +27,11 @@ es:
|
|||||||
ops_acknowledged: Incidencia asumida
|
ops_acknowledged: Incidencia asumida
|
||||||
ops_resolved: Incidencia resuelta
|
ops_resolved: Incidencia resuelta
|
||||||
ops_muted: Notificaciones silenciadas durante 24 horas
|
ops_muted: Notificaciones silenciadas durante 24 horas
|
||||||
|
stream_node_created: "Nodo lab %{slug} provisionado."
|
||||||
|
stream_node_destroyed: "Nodo %{slug} eliminado."
|
||||||
|
stream_node_draining: "Nodo %{slug} en drain."
|
||||||
|
autoscale_kill_on: "Kill-switch del autoscaler activado. Sin scale-out automático."
|
||||||
|
autoscale_kill_off: "Kill-switch del autoscaler desactivado (hace falta STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
|
comped_granted: "Suscripción de cortesía %{plan} activada para %{club}."
|
||||||
comped_revoked: "Suscripción de cortesía revocada para %{club}."
|
comped_revoked: "Suscripción de cortesía revocada para %{club}."
|
||||||
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
session_already_terminated: "La sesión ya ha finalizado (%{status})."
|
||||||
@@ -111,6 +117,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:
|
||||||
@@ -26,6 +27,11 @@ fr:
|
|||||||
ops_acknowledged: Incident pris en charge
|
ops_acknowledged: Incident pris en charge
|
||||||
ops_resolved: Incident résolu
|
ops_resolved: Incident résolu
|
||||||
ops_muted: Notifications suspendues pendant 24 heures
|
ops_muted: Notifications suspendues pendant 24 heures
|
||||||
|
stream_node_created: "Nœud lab %{slug} provisionné."
|
||||||
|
stream_node_destroyed: "Nœud %{slug} supprimé."
|
||||||
|
stream_node_draining: "Nœud %{slug} en drain."
|
||||||
|
autoscale_kill_on: "Kill-switch autoscaler activé. Pas de scale-out automatique."
|
||||||
|
autoscale_kill_off: "Kill-switch autoscaler désactivé (nécessite aussi STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
|
comped_granted: "Abonnement offert %{plan} activé pour %{club}."
|
||||||
comped_revoked: "Abonnement offert révoqué pour %{club}."
|
comped_revoked: "Abonnement offert révoqué pour %{club}."
|
||||||
session_already_terminated: "Session déjà terminée (%{status})."
|
session_already_terminated: "Session déjà terminée (%{status})."
|
||||||
@@ -111,6 +117,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:
|
||||||
@@ -26,6 +27,11 @@ it:
|
|||||||
ops_acknowledged: Incidente preso in carico
|
ops_acknowledged: Incidente preso in carico
|
||||||
ops_resolved: Incidente risolto
|
ops_resolved: Incidente risolto
|
||||||
ops_muted: Notifiche sospese per 24 ore
|
ops_muted: Notifiche sospese per 24 ore
|
||||||
|
stream_node_created: "Nodo lab %{slug} provisionato."
|
||||||
|
stream_node_destroyed: "Nodo %{slug} rimosso."
|
||||||
|
stream_node_draining: "Nodo %{slug} in drain (niente nuove sessioni)."
|
||||||
|
autoscale_kill_on: "Kill-switch autoscaler attivato. Nessun scale-out automatico."
|
||||||
|
autoscale_kill_off: "Kill-switch autoscaler disattivato (serve comunque STREAM_AUTOSCALE_ENABLED=1)."
|
||||||
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
|
comped_granted: "Abbonamento omaggio %{plan} attivato per %{club}."
|
||||||
comped_revoked: "Abbonamento omaggio revocato per %{club}."
|
comped_revoked: "Abbonamento omaggio revocato per %{club}."
|
||||||
session_already_terminated: "Sessione già terminata (%{status})."
|
session_already_terminated: "Sessione già terminata (%{status})."
|
||||||
@@ -111,6 +117,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
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ de:
|
|||||||
first_team_name_label: Teamname
|
first_team_name_label: Teamname
|
||||||
first_team_name_placeholder: "z. B. Under 15"
|
first_team_name_placeholder: "z. B. Under 15"
|
||||||
plan_label: "Starttarif (für den gesamten Verein)"
|
plan_label: "Starttarif (für den gesamten Verein)"
|
||||||
plan_free: "Free — 1 Übertragungsverantwortlicher pro Team, 1 Livestream"
|
plan_free: "Free — 1 Streaming-Konto pro Team, 1 Livestream"
|
||||||
plan_light: Premium Light
|
plan_light: Premium Light
|
||||||
plan_full: Premium Full
|
plan_full: Premium Full
|
||||||
interval_label: Premium-Abrechnung
|
interval_label: Premium-Abrechnung
|
||||||
interval_yearly: "Jährlich — 40 €/Jahr (Light) oder 200 €/Jahr (Full)"
|
interval_yearly: "Jährlich — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
|
||||||
interval_monthly: "Monatlich — 5 €/Monat (Light) oder 20 €/Monat (Full)"
|
interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
|
||||||
submit: Verein erstellen
|
submit: Verein erstellen
|
||||||
billing:
|
billing:
|
||||||
title: "Abonnement — %{name}"
|
title: "Abonnement — %{name}"
|
||||||
@@ -170,8 +170,8 @@ de:
|
|||||||
self_assign_submit: Das mache ich — Übertragungsverantwortlicher
|
self_assign_submit: Das mache ich — Übertragungsverantwortlicher
|
||||||
invite_other_heading: Eine weitere E-Mail einladen
|
invite_other_heading: Eine weitere E-Mail einladen
|
||||||
email_label: E-Mail des Übertragungsverantwortlichen
|
email_label: E-Mail des Übertragungsverantwortlichen
|
||||||
submit: Zugangslink erstellen
|
submit: Zugangslink per E-Mail senden
|
||||||
share_hint: "Teilen (7 Tage gültig):"
|
share_hint: "Link auch per E-Mail gesendet (7 Tage gültig):"
|
||||||
billing:
|
billing:
|
||||||
title: "Abonnement — %{name}"
|
title: "Abonnement — %{name}"
|
||||||
team_label: "Team:"
|
team_label: "Team:"
|
||||||
@@ -208,6 +208,10 @@ de:
|
|||||||
revoke_confirm: "Zugriff von %{name} widerrufen?"
|
revoke_confirm: "Zugriff von %{name} widerrufen?"
|
||||||
no_staff: Noch keine Übertragungsverantwortlichen.
|
no_staff: Noch keine Übertragungsverantwortlichen.
|
||||||
invite_someone: Jemanden einladen
|
invite_someone: Jemanden einladen
|
||||||
|
invite_form_heading: Account zum Übertragen einladen
|
||||||
|
invite_form_hint: "Das ist nicht der Kader (Spieler/Staff vor Ort). E-Mail eingeben: wir senden den Link automatisch; du kannst ihn auch kopieren und teilen."
|
||||||
|
invite_once_note: "Bei Bedarf den Link unten auch für einen zweiten Versand kopieren (WhatsApp, SMS)."
|
||||||
|
copied: Kopiert
|
||||||
pending_invitations_heading: Ausstehende Einladungen
|
pending_invitations_heading: Ausstehende Einladungen
|
||||||
col_email_short: E-Mail
|
col_email_short: E-Mail
|
||||||
col_expires: Läuft ab
|
col_expires: Läuft ab
|
||||||
@@ -423,6 +427,9 @@ de:
|
|||||||
upgrade_info_line: "Upgrade (höherer Tarif oder Wechsel zur jährlichen Abrechnung): Die Differenz wird sofort von der gespeicherten Stripe-Karte abgebucht."
|
upgrade_info_line: "Upgrade (höherer Tarif oder Wechsel zur jährlichen Abrechnung): Die Differenz wird sofort von der gespeicherten Stripe-Karte abgebucht."
|
||||||
downgrade_info_line: "Downgrade zwischen kostenpflichtigen Tarifen (z. B. Full → Light): Der neue Tarif gilt ab der nächsten Verlängerung; bis dahin bleiben die Funktionen des aktuellen Tarifs ohne zusätzliche Kosten erhalten."
|
downgrade_info_line: "Downgrade zwischen kostenpflichtigen Tarifen (z. B. Full → Light): Der neue Tarif gilt ab der nächsten Verlängerung; bis dahin bleiben die Funktionen des aktuellen Tarifs ohne zusätzliche Kosten erhalten."
|
||||||
already_requested_line: "Du hast %{plan} bereits ab dem %{when} angefordert: Bis dahin bleibt %{current_plan} aktiv."
|
already_requested_line: "Du hast %{plan} bereits ab dem %{when} angefordert: Bis dahin bleibt %{current_plan} aktiv."
|
||||||
|
prices:
|
||||||
|
yearly: "%{amount}/Jahr"
|
||||||
|
monthly: "%{amount}/Monat"
|
||||||
regia:
|
regia:
|
||||||
meta_title_fallback: "Regie — Match Live TV"
|
meta_title_fallback: "Regie — Match Live TV"
|
||||||
title: "Regie — %{home} vs %{away}"
|
title: "Regie — %{home} vs %{away}"
|
||||||
@@ -450,6 +457,8 @@ de:
|
|||||||
preview_off_hint: Vorschau aus · kein Video-Download
|
preview_off_hint: Vorschau aus · kein Video-Download
|
||||||
pause: Pausieren
|
pause: Pausieren
|
||||||
resume: Livestream fortsetzen
|
resume: Livestream fortsetzen
|
||||||
|
mute: Audio stummschalten
|
||||||
|
unmute: Audio einschalten
|
||||||
stop: Livestream beenden
|
stop: Livestream beenden
|
||||||
min_quality:
|
min_quality:
|
||||||
label: Minimale Übertragungsqualität
|
label: Minimale Übertragungsqualität
|
||||||
@@ -511,9 +520,12 @@ de:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Übertragung fortgesetzt
|
resumed: Übertragung fortgesetzt
|
||||||
paused_cover: "Übertragung pausiert — Deckbild auf Sendung"
|
paused_cover: "Übertragung pausiert — Deckbild auf Sendung"
|
||||||
|
muted: "Audio stumm — YouTube empfängt das Mikrofon nicht"
|
||||||
|
unmuted: "Mikrofon-Audio wieder aktiv"
|
||||||
closed: Übertragung beendet
|
closed: Übertragung beendet
|
||||||
resume_error: Fehler beim Fortsetzen der Übertragung
|
resume_error: Fehler beim Fortsetzen der Übertragung
|
||||||
pause_error: Fehler beim Pausieren der Übertragung
|
pause_error: Fehler beim Pausieren der Übertragung
|
||||||
|
mute_error: Fehler beim Stummschalten
|
||||||
close_error: Fehler beim Beenden
|
close_error: Fehler beim Beenden
|
||||||
close_confirm: Übertragung endgültig beenden?
|
close_confirm: Übertragung endgültig beenden?
|
||||||
link_unavailable: Link nicht verfügbar
|
link_unavailable: Link nicht verfügbar
|
||||||
@@ -628,6 +640,8 @@ de:
|
|||||||
now_staff: Dein Konto ist jetzt Übertragungsverantwortlicher.
|
now_staff: Dein Konto ist jetzt Übertragungsverantwortlicher.
|
||||||
staff_role_removed: Staff-Rolle von deinem Konto entfernt.
|
staff_role_removed: Staff-Rolle von deinem Konto entfernt.
|
||||||
invite_link_generated: "Einladungslink erstellt (7 Tage gültig)"
|
invite_link_generated: "Einladungslink erstellt (7 Tage gültig)"
|
||||||
|
invite_email_sent: "Einladung an %{email} gesendet (Link 7 Tage gültig)"
|
||||||
|
invite_email_failed: "Einladung erstellt, aber die E-Mail an %{email} konnte nicht gesendet werden. Kopiere und teile den Link unten."
|
||||||
access_revoked: Zugriff widerrufen
|
access_revoked: Zugriff widerrufen
|
||||||
invitation_canceled: Einladung storniert
|
invitation_canceled: Einladung storniert
|
||||||
youtube_disconnected: YouTube-Kanal des Vereins getrennt
|
youtube_disconnected: YouTube-Kanal des Vereins getrennt
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ en:
|
|||||||
first_team_name_label: Team name
|
first_team_name_label: Team name
|
||||||
first_team_name_placeholder: "e.g. Under 15"
|
first_team_name_placeholder: "e.g. Under 15"
|
||||||
plan_label: "Starting plan (for the whole club)"
|
plan_label: "Starting plan (for the whole club)"
|
||||||
plan_free: "Free — 1 broadcast manager per team, 1 live stream"
|
plan_free: "Free — 1 transmitter account per team, 1 live stream"
|
||||||
plan_light: Premium Light
|
plan_light: Premium Light
|
||||||
plan_full: Premium Full
|
plan_full: Premium Full
|
||||||
interval_label: Premium billing
|
interval_label: Premium billing
|
||||||
interval_yearly: "Yearly — €40/year (Light) or €200/year (Full)"
|
interval_yearly: "Yearly — €59/year (Light) or €199/year (Full)"
|
||||||
interval_monthly: "Monthly — €5/month (Light) or €20/month (Full)"
|
interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
|
||||||
submit: Create club
|
submit: Create club
|
||||||
billing:
|
billing:
|
||||||
title: "Subscription — %{name}"
|
title: "Subscription — %{name}"
|
||||||
@@ -165,8 +165,8 @@ en:
|
|||||||
self_assign_submit: I'll do it — broadcast manager
|
self_assign_submit: I'll do it — broadcast manager
|
||||||
invite_other_heading: Invite another email
|
invite_other_heading: Invite another email
|
||||||
email_label: Broadcast manager's email
|
email_label: Broadcast manager's email
|
||||||
submit: Generate access link
|
submit: Send invite by email
|
||||||
share_hint: "Share (valid for 7 days):"
|
share_hint: "Link also sent by email (valid for 7 days):"
|
||||||
billing:
|
billing:
|
||||||
title: "Subscription — %{name}"
|
title: "Subscription — %{name}"
|
||||||
team_label: "Team:"
|
team_label: "Team:"
|
||||||
@@ -203,6 +203,10 @@ en:
|
|||||||
revoke_confirm: "Revoke access for %{name}?"
|
revoke_confirm: "Revoke access for %{name}?"
|
||||||
no_staff: No broadcast managers yet.
|
no_staff: No broadcast managers yet.
|
||||||
invite_someone: Invite someone
|
invite_someone: Invite someone
|
||||||
|
invite_form_heading: Invite an account to broadcast
|
||||||
|
invite_form_hint: "This is not the roster (players/on-court staff). Enter the email: we send the link automatically; you can also copy and share it."
|
||||||
|
invite_once_note: "If needed, copy the link below for a second share (WhatsApp, SMS)."
|
||||||
|
copied: Copied
|
||||||
pending_invitations_heading: Pending invitations
|
pending_invitations_heading: Pending invitations
|
||||||
col_email_short: Email
|
col_email_short: Email
|
||||||
col_expires: Expires
|
col_expires: Expires
|
||||||
@@ -418,6 +422,9 @@ en:
|
|||||||
upgrade_info_line: "Upgrade (higher plan or switch to annual billing): the difference is charged immediately to the saved Stripe card."
|
upgrade_info_line: "Upgrade (higher plan or switch to annual billing): the difference is charged immediately to the saved Stripe card."
|
||||||
downgrade_info_line: "Downgrade between paid plans (e.g. Full → Light): the new plan takes effect from the next renewal; until then the current plan's features remain, with no additional charge now."
|
downgrade_info_line: "Downgrade between paid plans (e.g. Full → Light): the new plan takes effect from the next renewal; until then the current plan's features remain, with no additional charge now."
|
||||||
already_requested_line: "You already requested %{plan} from %{when}: until then %{current_plan} stays active."
|
already_requested_line: "You already requested %{plan} from %{when}: until then %{current_plan} stays active."
|
||||||
|
prices:
|
||||||
|
yearly: "%{amount}/year"
|
||||||
|
monthly: "%{amount}/month"
|
||||||
regia:
|
regia:
|
||||||
meta_title_fallback: "Control room — Match Live TV"
|
meta_title_fallback: "Control room — Match Live TV"
|
||||||
title: "Control room — %{home} vs %{away}"
|
title: "Control room — %{home} vs %{away}"
|
||||||
@@ -445,6 +452,8 @@ en:
|
|||||||
preview_off_hint: Preview off · no video download
|
preview_off_hint: Preview off · no video download
|
||||||
pause: Pause stream
|
pause: Pause stream
|
||||||
resume: Resume stream
|
resume: Resume stream
|
||||||
|
mute: Mute audio
|
||||||
|
unmute: Unmute audio
|
||||||
stop: End stream
|
stop: End stream
|
||||||
min_quality:
|
min_quality:
|
||||||
label: Minimum streaming quality
|
label: Minimum streaming quality
|
||||||
@@ -506,9 +515,12 @@ en:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Stream resumed
|
resumed: Stream resumed
|
||||||
paused_cover: "Stream paused — cover on air"
|
paused_cover: "Stream paused — cover on air"
|
||||||
|
muted: "Audio muted — YouTube is not receiving the microphone"
|
||||||
|
unmuted: "Microphone audio restored"
|
||||||
closed: Stream closed
|
closed: Stream closed
|
||||||
resume_error: Error resuming the stream
|
resume_error: Error resuming the stream
|
||||||
pause_error: Error pausing the stream
|
pause_error: Error pausing the stream
|
||||||
|
mute_error: Error muting audio
|
||||||
close_error: Error closing
|
close_error: Error closing
|
||||||
close_confirm: Permanently close the live stream?
|
close_confirm: Permanently close the live stream?
|
||||||
link_unavailable: Link not available
|
link_unavailable: Link not available
|
||||||
@@ -623,6 +635,8 @@ en:
|
|||||||
now_staff: Your account is now a broadcast manager.
|
now_staff: Your account is now a broadcast manager.
|
||||||
staff_role_removed: Staff role removed from your account.
|
staff_role_removed: Staff role removed from your account.
|
||||||
invite_link_generated: "Invite link generated (valid for 7 days)"
|
invite_link_generated: "Invite link generated (valid for 7 days)"
|
||||||
|
invite_email_sent: "Invite sent to %{email} (link valid for 7 days)"
|
||||||
|
invite_email_failed: "Invite created, but the email to %{email} could not be sent. Copy and share the link below."
|
||||||
access_revoked: Access revoked
|
access_revoked: Access revoked
|
||||||
invitation_canceled: Invitation canceled
|
invitation_canceled: Invitation canceled
|
||||||
youtube_disconnected: Club YouTube channel disconnected
|
youtube_disconnected: Club YouTube channel disconnected
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ es:
|
|||||||
first_team_name_label: Nombre del equipo
|
first_team_name_label: Nombre del equipo
|
||||||
first_team_name_placeholder: "ej. Sub-15"
|
first_team_name_placeholder: "ej. Sub-15"
|
||||||
plan_label: "Plan inicial (para todo el club)"
|
plan_label: "Plan inicial (para todo el club)"
|
||||||
plan_free: "Free — 1 responsable de transmisión por equipo, 1 directo"
|
plan_free: "Free — 1 cuenta de emisión por equipo, 1 directo"
|
||||||
plan_light: Premium Light
|
plan_light: Premium Light
|
||||||
plan_full: Premium Full
|
plan_full: Premium Full
|
||||||
interval_label: Facturación premium
|
interval_label: Facturación premium
|
||||||
interval_yearly: "Anual — 40 €/año (Light) o 200 €/año (Full)"
|
interval_yearly: "Anual — 59 €/año (Light) o 199 €/año (Full)"
|
||||||
interval_monthly: "Mensual — 5 €/mes (Light) o 20 €/mes (Full)"
|
interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
|
||||||
submit: Crear club
|
submit: Crear club
|
||||||
billing:
|
billing:
|
||||||
title: "Suscripción — %{name}"
|
title: "Suscripción — %{name}"
|
||||||
@@ -170,8 +170,8 @@ es:
|
|||||||
self_assign_submit: Yo me encargo — responsable de transmisión
|
self_assign_submit: Yo me encargo — responsable de transmisión
|
||||||
invite_other_heading: Invitar a otro correo
|
invite_other_heading: Invitar a otro correo
|
||||||
email_label: Correo del responsable de transmisión
|
email_label: Correo del responsable de transmisión
|
||||||
submit: Generar enlace de acceso
|
submit: Enviar invitación por email
|
||||||
share_hint: "Comparte (válido 7 días):"
|
share_hint: "Enlace también enviado por email (válido 7 días):"
|
||||||
billing:
|
billing:
|
||||||
title: "Suscripción — %{name}"
|
title: "Suscripción — %{name}"
|
||||||
team_label: "Equipo:"
|
team_label: "Equipo:"
|
||||||
@@ -208,6 +208,10 @@ es:
|
|||||||
revoke_confirm: "¿Revocar el acceso a %{name}?"
|
revoke_confirm: "¿Revocar el acceso a %{name}?"
|
||||||
no_staff: Aún no hay responsables de transmisión.
|
no_staff: Aún no hay responsables de transmisión.
|
||||||
invite_someone: Invitar a alguien
|
invite_someone: Invitar a alguien
|
||||||
|
invite_form_heading: Invitar una cuenta a transmitir
|
||||||
|
invite_form_hint: "No es la plantilla (jugadores/staff en pista). Introduce el email: enviamos el enlace automáticamente; también puedes copiarlo y compartirlo."
|
||||||
|
invite_once_note: "Si hace falta, copia también el enlace abajo para un segundo envío (WhatsApp, SMS)."
|
||||||
|
copied: Copiado
|
||||||
pending_invitations_heading: Invitaciones pendientes
|
pending_invitations_heading: Invitaciones pendientes
|
||||||
col_email_short: Correo
|
col_email_short: Correo
|
||||||
col_expires: Caduca
|
col_expires: Caduca
|
||||||
@@ -423,6 +427,9 @@ es:
|
|||||||
upgrade_info_line: "Mejora (plan superior o cambio a facturación anual): la diferencia se cobra de inmediato en la tarjeta guardada en Stripe."
|
upgrade_info_line: "Mejora (plan superior o cambio a facturación anual): la diferencia se cobra de inmediato en la tarjeta guardada en Stripe."
|
||||||
downgrade_info_line: "Downgrade entre planes de pago (p. ej. Full → Light): el nuevo plan se activa desde la próxima renovación; hasta entonces se mantienen las funciones del plan actual, sin cargo adicional ahora."
|
downgrade_info_line: "Downgrade entre planes de pago (p. ej. Full → Light): el nuevo plan se activa desde la próxima renovación; hasta entonces se mantienen las funciones del plan actual, sin cargo adicional ahora."
|
||||||
already_requested_line: "Ya has solicitado %{plan} desde el %{when}: hasta entonces sigue activo %{current_plan}."
|
already_requested_line: "Ya has solicitado %{plan} desde el %{when}: hasta entonces sigue activo %{current_plan}."
|
||||||
|
prices:
|
||||||
|
yearly: "%{amount}/año"
|
||||||
|
monthly: "%{amount}/mes"
|
||||||
regia:
|
regia:
|
||||||
meta_title_fallback: "Control — Match Live TV"
|
meta_title_fallback: "Control — Match Live TV"
|
||||||
title: "Control — %{home} vs %{away}"
|
title: "Control — %{home} vs %{away}"
|
||||||
@@ -450,6 +457,8 @@ es:
|
|||||||
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
preview_off_hint: Vista previa apagada · sin descarga de vídeo
|
||||||
pause: Pausar directo
|
pause: Pausar directo
|
||||||
resume: Reanudar directo
|
resume: Reanudar directo
|
||||||
|
mute: Silenciar audio
|
||||||
|
unmute: Reactivar audio
|
||||||
stop: Finalizar directo
|
stop: Finalizar directo
|
||||||
min_quality:
|
min_quality:
|
||||||
label: Calidad mínima de transmisión
|
label: Calidad mínima de transmisión
|
||||||
@@ -511,9 +520,12 @@ es:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Retransmisión reanudada
|
resumed: Retransmisión reanudada
|
||||||
paused_cover: "Retransmisión en pausa — cortinilla al aire"
|
paused_cover: "Retransmisión en pausa — cortinilla al aire"
|
||||||
|
muted: "Audio silenciado — YouTube no recibe el micrófono"
|
||||||
|
unmuted: "Audio del micrófono reactivado"
|
||||||
closed: Retransmisión finalizada
|
closed: Retransmisión finalizada
|
||||||
resume_error: Error al reanudar la retransmisión
|
resume_error: Error al reanudar la retransmisión
|
||||||
pause_error: Error al pausar la retransmisión
|
pause_error: Error al pausar la retransmisión
|
||||||
|
mute_error: Error al silenciar el audio
|
||||||
close_error: Error al cerrar
|
close_error: Error al cerrar
|
||||||
close_confirm: "¿Cerrar definitivamente la retransmisión?"
|
close_confirm: "¿Cerrar definitivamente la retransmisión?"
|
||||||
link_unavailable: Enlace no disponible
|
link_unavailable: Enlace no disponible
|
||||||
@@ -628,6 +640,8 @@ es:
|
|||||||
now_staff: Tu cuenta ahora es responsable de transmisión.
|
now_staff: Tu cuenta ahora es responsable de transmisión.
|
||||||
staff_role_removed: Rol de staff eliminado de tu cuenta.
|
staff_role_removed: Rol de staff eliminado de tu cuenta.
|
||||||
invite_link_generated: "Enlace de invitación generado (válido 7 días)"
|
invite_link_generated: "Enlace de invitación generado (válido 7 días)"
|
||||||
|
invite_email_sent: "Invitación enviada a %{email} (enlace válido 7 días)"
|
||||||
|
invite_email_failed: "Invitación creada, pero no se pudo enviar el email a %{email}. Copia y comparte el enlace de abajo."
|
||||||
access_revoked: Acceso revocado
|
access_revoked: Acceso revocado
|
||||||
invitation_canceled: Invitación anulada
|
invitation_canceled: Invitación anulada
|
||||||
youtube_disconnected: Canal de YouTube del club desconectado
|
youtube_disconnected: Canal de YouTube del club desconectado
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ fr:
|
|||||||
first_team_name_label: Nom de l'équipe
|
first_team_name_label: Nom de l'équipe
|
||||||
first_team_name_placeholder: "ex. Under 15"
|
first_team_name_placeholder: "ex. Under 15"
|
||||||
plan_label: "Forfait de départ (pour tout le club)"
|
plan_label: "Forfait de départ (pour tout le club)"
|
||||||
plan_free: "Gratuit — 1 responsable diffusion par équipe, 1 direct"
|
plan_free: "Gratuit — 1 compte diffuseur par équipe, 1 direct"
|
||||||
plan_light: Premium Light
|
plan_light: Premium Light
|
||||||
plan_full: Premium Full
|
plan_full: Premium Full
|
||||||
interval_label: Facturation premium
|
interval_label: Facturation premium
|
||||||
interval_yearly: "Annuel — 40 €/an (Light) ou 200 €/an (Full)"
|
interval_yearly: "Annuel — 59 €/an (Light) ou 199 €/an (Full)"
|
||||||
interval_monthly: "Mensuel — 5 €/mois (Light) ou 20 €/mois (Full)"
|
interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
|
||||||
submit: Créer le club
|
submit: Créer le club
|
||||||
billing:
|
billing:
|
||||||
title: "Abonnement — %{name}"
|
title: "Abonnement — %{name}"
|
||||||
@@ -170,8 +170,8 @@ fr:
|
|||||||
self_assign_submit: C'est moi — responsable diffusion
|
self_assign_submit: C'est moi — responsable diffusion
|
||||||
invite_other_heading: Inviter un autre e-mail
|
invite_other_heading: Inviter un autre e-mail
|
||||||
email_label: E-mail du responsable diffusion
|
email_label: E-mail du responsable diffusion
|
||||||
submit: Générer le lien d'accès
|
submit: Envoyer l'invitation par e-mail
|
||||||
share_hint: "Partage (valable 7 jours) :"
|
share_hint: "Lien aussi envoyé par e-mail (valable 7 jours) :"
|
||||||
billing:
|
billing:
|
||||||
title: "Abonnement — %{name}"
|
title: "Abonnement — %{name}"
|
||||||
team_label: "Équipe :"
|
team_label: "Équipe :"
|
||||||
@@ -208,6 +208,10 @@ fr:
|
|||||||
revoke_confirm: "Révoquer l'accès de %{name} ?"
|
revoke_confirm: "Révoquer l'accès de %{name} ?"
|
||||||
no_staff: Aucun responsable diffusion pour le moment.
|
no_staff: Aucun responsable diffusion pour le moment.
|
||||||
invite_someone: Inviter quelqu'un
|
invite_someone: Inviter quelqu'un
|
||||||
|
invite_form_heading: Inviter un compte à diffuser
|
||||||
|
invite_form_hint: "Ce n’est pas l’effectif (joueurs/staff sur le terrain). Saisissez l’e-mail : nous envoyons le lien automatiquement ; vous pouvez aussi le copier et le partager."
|
||||||
|
invite_once_note: "Si besoin, copiez aussi le lien ci-dessous pour un second envoi (WhatsApp, SMS)."
|
||||||
|
copied: Copié
|
||||||
pending_invitations_heading: Invitations en attente
|
pending_invitations_heading: Invitations en attente
|
||||||
col_email_short: E-mail
|
col_email_short: E-mail
|
||||||
col_expires: Expire
|
col_expires: Expire
|
||||||
@@ -423,6 +427,9 @@ fr:
|
|||||||
upgrade_info_line: "Upgrade (forfait supérieur ou passage à la facturation annuelle) : la différence est débitée immédiatement sur la carte Stripe enregistrée."
|
upgrade_info_line: "Upgrade (forfait supérieur ou passage à la facturation annuelle) : la différence est débitée immédiatement sur la carte Stripe enregistrée."
|
||||||
downgrade_info_line: "Downgrade entre forfaits payants (ex. Full → Light) : le nouveau forfait est actif dès le prochain renouvellement ; jusque-là, les fonctionnalités du forfait actuel restent actives, sans débit supplémentaire pour l'instant."
|
downgrade_info_line: "Downgrade entre forfaits payants (ex. Full → Light) : le nouveau forfait est actif dès le prochain renouvellement ; jusque-là, les fonctionnalités du forfait actuel restent actives, sans débit supplémentaire pour l'instant."
|
||||||
already_requested_line: "Vous avez déjà demandé %{plan} à partir du %{when} : jusque-là, %{current_plan} reste actif."
|
already_requested_line: "Vous avez déjà demandé %{plan} à partir du %{when} : jusque-là, %{current_plan} reste actif."
|
||||||
|
prices:
|
||||||
|
yearly: "%{amount}/an"
|
||||||
|
monthly: "%{amount}/mois"
|
||||||
regia:
|
regia:
|
||||||
meta_title_fallback: "Régie — Match Live TV"
|
meta_title_fallback: "Régie — Match Live TV"
|
||||||
title: "Régie — %{home} vs %{away}"
|
title: "Régie — %{home} vs %{away}"
|
||||||
@@ -450,6 +457,8 @@ fr:
|
|||||||
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
|
||||||
pause: Mettre en pause
|
pause: Mettre en pause
|
||||||
resume: Reprendre le direct
|
resume: Reprendre le direct
|
||||||
|
mute: Couper l'audio
|
||||||
|
unmute: Réactiver l'audio
|
||||||
stop: Terminer le direct
|
stop: Terminer le direct
|
||||||
min_quality:
|
min_quality:
|
||||||
label: Qualité minimale de transmission
|
label: Qualité minimale de transmission
|
||||||
@@ -511,9 +520,12 @@ fr:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Direct repris
|
resumed: Direct repris
|
||||||
paused_cover: "Direct en pause — habillage à l'antenne"
|
paused_cover: "Direct en pause — habillage à l'antenne"
|
||||||
|
muted: "Audio coupé — YouTube ne reçoit pas le micro"
|
||||||
|
unmuted: "Audio du micro rétabli"
|
||||||
closed: Direct terminé
|
closed: Direct terminé
|
||||||
resume_error: Erreur lors de la reprise du direct
|
resume_error: Erreur lors de la reprise du direct
|
||||||
pause_error: Erreur lors de la mise en pause du direct
|
pause_error: Erreur lors de la mise en pause du direct
|
||||||
|
mute_error: Erreur lors de la coupure audio
|
||||||
close_error: Erreur de clôture
|
close_error: Erreur de clôture
|
||||||
close_confirm: Clôturer définitivement le direct ?
|
close_confirm: Clôturer définitivement le direct ?
|
||||||
link_unavailable: Lien non disponible
|
link_unavailable: Lien non disponible
|
||||||
@@ -628,6 +640,8 @@ fr:
|
|||||||
now_staff: Ton compte est maintenant responsable diffusion.
|
now_staff: Ton compte est maintenant responsable diffusion.
|
||||||
staff_role_removed: Rôle staff retiré de ton compte.
|
staff_role_removed: Rôle staff retiré de ton compte.
|
||||||
invite_link_generated: "Lien d'invitation généré (valable 7 jours)"
|
invite_link_generated: "Lien d'invitation généré (valable 7 jours)"
|
||||||
|
invite_email_sent: "Invitation envoyée à %{email} (lien valable 7 jours)"
|
||||||
|
invite_email_failed: "Invitation créée, mais l'e-mail à %{email} n'a pas pu être envoyé. Copiez et partagez le lien ci-dessous."
|
||||||
access_revoked: Accès révoqué
|
access_revoked: Accès révoqué
|
||||||
invitation_canceled: Invitation annulée
|
invitation_canceled: Invitation annulée
|
||||||
youtube_disconnected: Chaîne YouTube du club déconnectée
|
youtube_disconnected: Chaîne YouTube du club déconnectée
|
||||||
|
|||||||
@@ -77,12 +77,12 @@ it:
|
|||||||
first_team_name_label: Nome squadra
|
first_team_name_label: Nome squadra
|
||||||
first_team_name_placeholder: "es. Under 15"
|
first_team_name_placeholder: "es. Under 15"
|
||||||
plan_label: "Piano iniziale (per tutta la società)"
|
plan_label: "Piano iniziale (per tutta la società)"
|
||||||
plan_free: "Free — 1 responsabile trasmissione per squadra, 1 live"
|
plan_free: "Free — 1 account trasmettitore per squadra, 1 live"
|
||||||
plan_light: Premium Light
|
plan_light: Premium Light
|
||||||
plan_full: Premium Full
|
plan_full: Premium Full
|
||||||
interval_label: Fatturazione premium
|
interval_label: Fatturazione premium
|
||||||
interval_yearly: "Annuale — €40/anno (Light) o €200/anno (Full)"
|
interval_yearly: "Annuale — €59/anno (Light) o €199/anno (Full)"
|
||||||
interval_monthly: "Mensile — €5/mese (Light) o €20/mese (Full)"
|
interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
|
||||||
submit: Crea società
|
submit: Crea società
|
||||||
billing:
|
billing:
|
||||||
title: "Abbonamento — %{name}"
|
title: "Abbonamento — %{name}"
|
||||||
@@ -165,8 +165,8 @@ it:
|
|||||||
self_assign_submit: Sono io — responsabile trasmissione
|
self_assign_submit: Sono io — responsabile trasmissione
|
||||||
invite_other_heading: Invita un'altra email
|
invite_other_heading: Invita un'altra email
|
||||||
email_label: Email del responsabile trasmissione
|
email_label: Email del responsabile trasmissione
|
||||||
submit: Genera link di accesso
|
submit: Invia invito per email
|
||||||
share_hint: "Condividi (valido 7 giorni):"
|
share_hint: "Link inviato anche per email (valido 7 giorni):"
|
||||||
billing:
|
billing:
|
||||||
title: "Abbonamento — %{name}"
|
title: "Abbonamento — %{name}"
|
||||||
team_label: "Squadra:"
|
team_label: "Squadra:"
|
||||||
@@ -203,6 +203,10 @@ it:
|
|||||||
revoke_confirm: "Revocare l'accesso a %{name}?"
|
revoke_confirm: "Revocare l'accesso a %{name}?"
|
||||||
no_staff: Nessun responsabile trasmissione.
|
no_staff: Nessun responsabile trasmissione.
|
||||||
invite_someone: Invita qualcuno
|
invite_someone: Invita qualcuno
|
||||||
|
invite_form_heading: Invita un account a trasmettere
|
||||||
|
invite_form_hint: "Non è l’organico (giocatori/staff in campo). Inserisci l’email: inviamo il link automaticamente; puoi anche copiarlo e condividerlo."
|
||||||
|
invite_once_note: "Se serve, copia il link anche qui sotto per un secondo invio (WhatsApp, SMS)."
|
||||||
|
copied: Copiato
|
||||||
pending_invitations_heading: Inviti in attesa
|
pending_invitations_heading: Inviti in attesa
|
||||||
col_email_short: Email
|
col_email_short: Email
|
||||||
col_expires: Scade
|
col_expires: Scade
|
||||||
@@ -418,6 +422,9 @@ it:
|
|||||||
upgrade_info_line: "Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe."
|
upgrade_info_line: "Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe."
|
||||||
downgrade_info_line: "Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora."
|
downgrade_info_line: "Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora."
|
||||||
already_requested_line: "Hai già richiesto %{plan} dal %{when}: fino ad allora resta attivo %{current_plan}."
|
already_requested_line: "Hai già richiesto %{plan} dal %{when}: fino ad allora resta attivo %{current_plan}."
|
||||||
|
prices:
|
||||||
|
yearly: "%{amount}/anno"
|
||||||
|
monthly: "%{amount}/mese"
|
||||||
regia:
|
regia:
|
||||||
meta_title_fallback: "Regia — Match Live TV"
|
meta_title_fallback: "Regia — Match Live TV"
|
||||||
title: "Regia — %{home} vs %{away}"
|
title: "Regia — %{home} vs %{away}"
|
||||||
@@ -445,6 +452,8 @@ it:
|
|||||||
preview_off_hint: Anteprima spenta · nessun download video
|
preview_off_hint: Anteprima spenta · nessun download video
|
||||||
pause: Metti in pausa
|
pause: Metti in pausa
|
||||||
resume: Riprendi diretta
|
resume: Riprendi diretta
|
||||||
|
mute: Silenzia audio
|
||||||
|
unmute: Riattiva audio
|
||||||
stop: Chiudi diretta
|
stop: Chiudi diretta
|
||||||
min_quality:
|
min_quality:
|
||||||
label: Qualità minima di trasmissione
|
label: Qualità minima di trasmissione
|
||||||
@@ -506,9 +515,12 @@ it:
|
|||||||
unknown_period: "—"
|
unknown_period: "—"
|
||||||
resumed: Diretta ripresa
|
resumed: Diretta ripresa
|
||||||
paused_cover: "Diretta in pausa — copertina in onda"
|
paused_cover: "Diretta in pausa — copertina in onda"
|
||||||
|
muted: "Audio silenziato — YouTube non riceve il microfono"
|
||||||
|
unmuted: "Audio microfono riattivato"
|
||||||
closed: Diretta chiusa
|
closed: Diretta chiusa
|
||||||
resume_error: Errore ripresa diretta
|
resume_error: Errore ripresa diretta
|
||||||
pause_error: Errore pausa diretta
|
pause_error: Errore pausa diretta
|
||||||
|
mute_error: Errore silenziamento audio
|
||||||
close_error: Errore chiusura
|
close_error: Errore chiusura
|
||||||
close_confirm: Chiudere definitivamente la diretta?
|
close_confirm: Chiudere definitivamente la diretta?
|
||||||
link_unavailable: Link non disponibile
|
link_unavailable: Link non disponibile
|
||||||
@@ -623,6 +635,8 @@ it:
|
|||||||
now_staff: Il tuo account è ora responsabile trasmissione.
|
now_staff: Il tuo account è ora responsabile trasmissione.
|
||||||
staff_role_removed: Ruolo staff rimosso dal tuo account.
|
staff_role_removed: Ruolo staff rimosso dal tuo account.
|
||||||
invite_link_generated: "Link invito generato (valido 7 giorni)"
|
invite_link_generated: "Link invito generato (valido 7 giorni)"
|
||||||
|
invite_email_sent: "Invito inviato a %{email} (link valido 7 giorni)"
|
||||||
|
invite_email_failed: "Invito creato, ma l’email a %{email} non è partita. Copia e condividi il link qui sotto."
|
||||||
access_revoked: Accesso revocato
|
access_revoked: Accesso revocato
|
||||||
invitation_canceled: Invito annullato
|
invitation_canceled: Invito annullato
|
||||||
youtube_disconnected: Canale YouTube della società scollegato
|
youtube_disconnected: Canale YouTube della società scollegato
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ de:
|
|||||||
open_text: "Öffnen: %{url}"
|
open_text: "Öffnen: %{url}"
|
||||||
archive_text: "Archiv: %{url}"
|
archive_text: "Archiv: %{url}"
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
transmission_invite:
|
||||||
|
subject: "Einladung zum Übertragen — %{team} (%{club})"
|
||||||
|
hello: Hallo,
|
||||||
|
body_html: "<strong>%{inviter}</strong> hat dich als Übertragungsverantwortliche/n für <strong>%{team}</strong> (%{club}) auf Match Live TV eingeladen."
|
||||||
|
body_text: "%{inviter} hat dich als Übertragungsverantwortliche/n für %{team} (%{club}) auf Match Live TV eingeladen."
|
||||||
|
cta: Einladung annehmen
|
||||||
|
link_html: <a href="%{url}">Einladung annehmen</a>
|
||||||
|
link_text: "Öffne diesen Link zum Annehmen:"
|
||||||
|
link_fallback: "Falls der Button nicht funktioniert, öffne diesen Link:"
|
||||||
|
steps: "Melde dich mit derselben E-Mail an oder registriere dich, nimm die Einladung an und starte Übertragungen in der Match-Live-TV-App."
|
||||||
|
expiry: "Der Link ist gültig bis %{date}."
|
||||||
|
ignore: Wenn du diese Nachricht nicht erwartet hast, kannst du sie ignorieren.
|
||||||
|
footer: "Match Live TV — %{email}"
|
||||||
invoice:
|
invoice:
|
||||||
subject: "Rechnung %{number} — Match Live TV"
|
subject: "Rechnung %{number} — Match Live TV"
|
||||||
attachment_prefix: rechnung
|
attachment_prefix: rechnung
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ en:
|
|||||||
open_text: "Open: %{url}"
|
open_text: "Open: %{url}"
|
||||||
archive_text: "Archive: %{url}"
|
archive_text: "Archive: %{url}"
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
transmission_invite:
|
||||||
|
subject: "Broadcast invite — %{team} (%{club})"
|
||||||
|
hello: Hi,
|
||||||
|
body_html: "<strong>%{inviter}</strong> invited you as a broadcast manager for <strong>%{team}</strong> (%{club}) on Match Live TV."
|
||||||
|
body_text: "%{inviter} invited you as a broadcast manager for %{team} (%{club}) on Match Live TV."
|
||||||
|
cta: Accept the invite
|
||||||
|
link_html: <a href="%{url}">Accept the invite</a>
|
||||||
|
link_text: "Open this link to accept:"
|
||||||
|
link_fallback: "If the button does not work, open this link:"
|
||||||
|
steps: "Sign in or register with this same email, accept the invite, then start streams from the Match Live TV app."
|
||||||
|
expiry: "The link is valid until %{date}."
|
||||||
|
ignore: If you were not expecting this message, you can ignore it.
|
||||||
|
footer: "Match Live TV — %{email}"
|
||||||
invoice:
|
invoice:
|
||||||
subject: "Invoice %{number} — Match Live TV"
|
subject: "Invoice %{number} — Match Live TV"
|
||||||
attachment_prefix: invoice
|
attachment_prefix: invoice
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ es:
|
|||||||
open_text: "Abrir: %{url}"
|
open_text: "Abrir: %{url}"
|
||||||
archive_text: "Archivo: %{url}"
|
archive_text: "Archivo: %{url}"
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
transmission_invite:
|
||||||
|
subject: "Invitación a transmitir — %{team} (%{club})"
|
||||||
|
hello: Hola,
|
||||||
|
body_html: "<strong>%{inviter}</strong> te ha invitado como responsable de transmisión de <strong>%{team}</strong> (%{club}) en Match Live TV."
|
||||||
|
body_text: "%{inviter} te ha invitado como responsable de transmisión de %{team} (%{club}) en Match Live TV."
|
||||||
|
cta: Aceptar la invitación
|
||||||
|
link_html: <a href="%{url}">Aceptar la invitación</a>
|
||||||
|
link_text: "Abre este enlace para aceptar:"
|
||||||
|
link_fallback: "Si el botón no funciona, abre este enlace:"
|
||||||
|
steps: "Inicia sesión o regístrate con este mismo email, acepta la invitación y lanza las directos desde la app Match Live TV."
|
||||||
|
expiry: "El enlace es válido hasta el %{date}."
|
||||||
|
ignore: Si no esperabas este mensaje, puedes ignorarlo.
|
||||||
|
footer: "Match Live TV — %{email}"
|
||||||
invoice:
|
invoice:
|
||||||
subject: "Factura %{number} — Match Live TV"
|
subject: "Factura %{number} — Match Live TV"
|
||||||
attachment_prefix: factura
|
attachment_prefix: factura
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ fr:
|
|||||||
open_text: "Ouvrir : %{url}"
|
open_text: "Ouvrir : %{url}"
|
||||||
archive_text: "Archive : %{url}"
|
archive_text: "Archive : %{url}"
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
transmission_invite:
|
||||||
|
subject: "Invitation à diffuser — %{team} (%{club})"
|
||||||
|
hello: Bonjour,
|
||||||
|
body_html: "<strong>%{inviter}</strong> vous a invité comme responsable diffusion pour <strong>%{team}</strong> (%{club}) sur Match Live TV."
|
||||||
|
body_text: "%{inviter} vous a invité comme responsable diffusion pour %{team} (%{club}) sur Match Live TV."
|
||||||
|
cta: Accepter l’invitation
|
||||||
|
link_html: <a href="%{url}">Accepter l’invitation</a>
|
||||||
|
link_text: "Ouvrez ce lien pour accepter :"
|
||||||
|
link_fallback: "Si le bouton ne fonctionne pas, ouvrez ce lien :"
|
||||||
|
steps: "Connectez-vous ou inscrivez-vous avec cette même adresse e-mail, acceptez l’invitation, puis lancez les directs depuis l’app Match Live TV."
|
||||||
|
expiry: "Le lien est valable jusqu’au %{date}."
|
||||||
|
ignore: Si vous n’attendiez pas ce message, vous pouvez l’ignorer.
|
||||||
|
footer: "Match Live TV — %{email}"
|
||||||
invoice:
|
invoice:
|
||||||
subject: "Facture %{number} — Match Live TV"
|
subject: "Facture %{number} — Match Live TV"
|
||||||
attachment_prefix: facture
|
attachment_prefix: facture
|
||||||
|
|||||||
@@ -31,6 +31,19 @@ it:
|
|||||||
open_text: "Apri: %{url}"
|
open_text: "Apri: %{url}"
|
||||||
archive_text: "Archivio: %{url}"
|
archive_text: "Archivio: %{url}"
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
transmission_invite:
|
||||||
|
subject: "Invito a trasmettere — %{team} (%{club})"
|
||||||
|
hello: Ciao,
|
||||||
|
body_html: "<strong>%{inviter}</strong> ti ha invitato come responsabile trasmissione per <strong>%{team}</strong> (%{club}) su Match Live TV."
|
||||||
|
body_text: "%{inviter} ti ha invitato come responsabile trasmissione per %{team} (%{club}) su Match Live TV."
|
||||||
|
cta: Accetta l’invito
|
||||||
|
link_html: <a href="%{url}">Accetta l’invito</a>
|
||||||
|
link_text: "Apri questo link per accettare:"
|
||||||
|
link_fallback: "Se il pulsante non funziona, apri questo link:"
|
||||||
|
steps: "Accedi o registrati con questa stessa email, accetta l’invito e avvia le dirette dall’app Match Live TV."
|
||||||
|
expiry: "Il link è valido fino al %{date}."
|
||||||
|
ignore: Se non ti aspettavi questo messaggio, puoi ignorarlo.
|
||||||
|
footer: "Match Live TV — %{email}"
|
||||||
invoice:
|
invoice:
|
||||||
subject: "Fattura %{number} — Match Live TV"
|
subject: "Fattura %{number} — Match Live TV"
|
||||||
attachment_prefix: fattura
|
attachment_prefix: fattura
|
||||||
|
|||||||
@@ -2,32 +2,80 @@ de:
|
|||||||
pages:
|
pages:
|
||||||
features:
|
features:
|
||||||
meta_title: "Streaming-Funktionen für Jugendspiele — Match Live TV"
|
meta_title: "Streaming-Funktionen für Jugendspiele — Match Live TV"
|
||||||
meta_description: "App für Livestreams vom Handy: Spiel filmen, Spielstand aktualisieren, Link mit Eltern und Großeltern teilen. Spielarchiv und Pläne für Jugend-Sportvereine."
|
meta_description: "Übertragen Sie Vereinsspiele vom Smartphone mit persönlichen Konten, Spielstand per Link, Live auf Match Live TV und YouTube sowie Replay und Archiv. Ohne komplizierte Technik."
|
||||||
title: Funktionen
|
hero_title: "Die Livestreams Ihres Vereins. Ohne geteilte Passwörter, ohne komplizierte Technik."
|
||||||
lead: "Alles, was nötig ist, damit auch die, die nicht in der Halle sein können, das Spiel verfolgen: einfach für Trainer und Helfer, praktisch für Eltern, Großeltern und Verwandte."
|
hero_lead: "Laden Sie Trainer, Verantwortliche und Eltern ein, mit dem eigenen Konto zu streamen. Ein Smartphone reicht: Match Live TV übernimmt Zugänge, Spielstand, Live, Replay und Veröffentlichung."
|
||||||
staff_section_title: Für Verein und Team
|
hero_alt: Smartphone auf Stativ für den Livestream und zweites Handy für den Spielstand in der Halle
|
||||||
staff_invite_title: Einladungen mit eigener E-Mail
|
flow_aria: Ablauf der Übertragung
|
||||||
staff_invite_body: "Der Vorsitzende oder Inhaber lädt ein, wer filmt und wer den Spielstand aktualisiert: jeder meldet sich mit seiner eigenen E-Mail an, ohne Passwörter zu teilen."
|
flow_phone: Ein Smartphone
|
||||||
staff_access_title: Zugriffsverwaltung
|
flow_account: Ein persönliches Konto
|
||||||
staff_access_body: Sie können einem Helfer den Zugriff entziehen oder eine noch offene Einladung vom Team-Dashboard aus stornieren.
|
flow_live: Sie sind live
|
||||||
staff_plans_title: Maßgeschneiderte Pläne
|
how_title: Von der Halle zu den Zuschauern in wenigen Minuten
|
||||||
staff_plans_body: "Mit Free, Premium Light oder Premium Full erhöhen Sie, wie viele Personen live helfen können und wie viele Spiele parallel laufen können."
|
how_1_title: Spiel anlegen
|
||||||
live_section_title: Auf dem Platz und live
|
how_1_body: Der Verein bereitet das Spiel mit Team, Gegner, Datum und Uhrzeit vor.
|
||||||
live_phone_title: Vom Handy filmen
|
how_2_title: App öffnen
|
||||||
live_phone_body: "Handy aufstellen und den Livestream starten: auch bei schwachem 4G bleibt die Übertragung so stabil wie möglich, ohne Kabelsalat oder Mischpult."
|
how_2_body: Die beauftragte Person meldet sich in der Match-Live-TV-App mit dem eigenen persönlichen Konto an.
|
||||||
live_score_title: Spielstand vom zweiten Handy
|
how_3_title: Livestream starten
|
||||||
live_score_body: "Ein weiterer Helfer aktualisiert Sätze und Punkte von einem zweiten Handy aus: einfach den Code scannen, der auf dem Bildschirm des Filmenden erscheint."
|
how_3_body: Ein Smartphone reicht. Keine spezielle Ausrüstung und keine komplizierte Einrichtung.
|
||||||
live_link_title: Link für Familien und Fans
|
how_4_title: Spielstand führen
|
||||||
live_link_body: "Teilen Sie einen Link: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer, ohne App-Installation."
|
how_4_body: Eine andere Person kann Ergebnis und Spielstand über einen einfachen Link aktualisieren – ohne App und ohne Konto.
|
||||||
live_archive_title: Spielarchiv
|
how_5_title: Replay
|
||||||
live_archive_body: "Mit Premium Light oder Full bleiben die Spiele auf dem Server gespeichert (30 oder 90 Tage) und Sie können sie bei Bedarf aufs Handy herunterladen."
|
how_5_body: Nach dem Livestream kann das Spiel gespeichert und als Replay verfügbar gemacht werden – je nach Plan.
|
||||||
live_youtube_mltv_title: Auch auf YouTube (Match Live TV)
|
accounts_title: Jeder mit dem eigenen Konto. Das Vereins-Passwort bleibt beim Verein.
|
||||||
live_youtube_mltv_body: "Mit Premium Light ist der Livestream zusätzlich zum Link auf der Website auch auf dem YouTube-Kanal von Match Live TV zu sehen."
|
accounts_lead: Username und Passwort des Hauptkontos müssen nicht mehr geteilt werden. Verantwortliche, Trainer und beauftragte Eltern erhalten einen persönlichen, widerrufbaren Zugang.
|
||||||
live_youtube_club_title: YouTube-Kanal des Vereins
|
accounts_invite_title: Einladen
|
||||||
live_youtube_club_body: "Mit Premium Full können Sie denselben Livestream auch auf den offiziellen YouTube-Kanal des Vereins senden."
|
accounts_invite_body: Der Verein lädt Trainer, Verantwortliche oder Eltern ein, die die Übertragung übernehmen.
|
||||||
live_website_title: Auf der Website des Vereins
|
accounts_transmit_title: Übertragen
|
||||||
live_website_body: "Integration zum Einbetten des Players auf der Vereinswebsite — demnächst mit Premium Full."
|
accounts_transmit_body: Jeder meldet sich in der App mit den eigenen Zugangsdaten an.
|
||||||
cta_signup: Team registrieren
|
accounts_revoke_title: Widerrufen
|
||||||
|
accounts_revoke_body: Wenn jemand nicht mehr mit Team oder Verein zusammenarbeitet, kann der Zugang widerrufen werden, ohne das Passwort der anderen zu ändern.
|
||||||
|
accounts_highlight: Keine geteilten Passwörter. Jede beauftragte Person hat den eigenen persönlichen Zugang.
|
||||||
|
score_title: Wer filmt, filmt. Wer den Spielstand führt, führt den Spielstand.
|
||||||
|
score_lead: Wer überträgt, kann sich auf den Bildausschnitt konzentrieren. Eine andere Person – auch von der Tribüne – öffnet einen einfachen Link und aktualisiert den Spielstand in Echtzeit.
|
||||||
|
score_item_app: Keine App-Installation
|
||||||
|
score_item_signup: Keine Registrierung
|
||||||
|
score_item_password: Kein Passwort
|
||||||
|
score_item_link: Einfach den geteilten Link öffnen
|
||||||
|
score_card_aria: Zugang zur Spielstand-Regie
|
||||||
|
score_card_title: Regie per Link
|
||||||
|
score_card_body: Wer Sätze und Punkte aktualisiert, öffnet einen geteilten Link. Kein Konto, keine Einladung.
|
||||||
|
access_link: Zugang per Link
|
||||||
|
distribute_title: Ein Spiel, mehrere Wege es zu verfolgen
|
||||||
|
distribute_lead: Vom Smartphone in der Halle erreicht der Livestream die Zuschauer auf Match Live TV und – je nach Plan – auch YouTube.
|
||||||
|
distribute_aria: Wohin der Livestream geht
|
||||||
|
distribute_phone: Smartphone
|
||||||
|
distribute_mltv: Match Live TV
|
||||||
|
distribute_viewers: Zuschauer
|
||||||
|
distribute_youtube: YouTube
|
||||||
|
youtube_badge: Premium Full
|
||||||
|
youtube_title: Der Livestream bleibt bei Ihrem Verein
|
||||||
|
youtube_body: Mit Premium Full können Sie auch auf den offiziellen YouTube-Kanal des Vereins streamen. So behält der Verein Publikum, Abonnenten, Aufrufe, Spielarchiv und Sponsorensichtbarkeit.
|
||||||
|
youtube_mock_channel: Offizieller Vereinskanal
|
||||||
|
youtube_mock_subs: Abonnenten und Aufrufe des Vereins
|
||||||
|
replay_title: Das Spiel endet. Die Erinnerung bleibt.
|
||||||
|
replay_lead: Bei Plänen mit Replay bleiben Spiele auf den Match-Live-TV-Servern gespeichert und können später angesehen werden. Die Aufbewahrungsdauer hängt vom Plan ab.
|
||||||
|
replay_item_server: Speicherung auf Match-Live-TV-Servern
|
||||||
|
replay_item_plan: Archivdauer je nach Plan
|
||||||
|
replay_item_download: Video bei Bedarf aufs Handy herunterladen
|
||||||
|
replay_card_title: Spielarchiv
|
||||||
|
replay_card_body: Spiele nach dem Schlusspfiff noch einmal ansehen und bei Bedarf herunterladen.
|
||||||
|
replay_mock_1_title: Rossi vs Neri
|
||||||
|
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||||
|
replay_mock_2_title: Rossi vs Blu
|
||||||
|
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||||
|
replay_mock_3_title: Rossi vs Bianchi
|
||||||
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
|
more_title: Auch das gehört zum Produkt
|
||||||
|
more_stable_title: Livestream vom Smartphone
|
||||||
|
more_stable_body: Handy aufstellen und Übertragung starten. Auch bei schwachem 4G bleibt der Livestream so stabil wie möglich – ohne Kabel oder Mischpult.
|
||||||
|
more_link_title: Link für Familien und Fans
|
||||||
|
more_link_body: "Einen Link teilen: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer – ohne App."
|
||||||
|
more_yt_mltv_title: Auch auf YouTube Match Live TV
|
||||||
|
more_yt_mltv_body: Der Livestream kann zusätzlich zum Link auf der Website auch auf dem YouTube-Kanal von Match Live TV sichtbar sein.
|
||||||
|
cta_title: Bereit für das nächste Spiel?
|
||||||
|
cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig.
|
||||||
|
cta_primary: Kostenlos starten
|
||||||
|
cta_secondary: Pläne vergleichen
|
||||||
pricing:
|
pricing:
|
||||||
meta_title: "Preise für Jugendspiel-Streaming — Match Live TV"
|
meta_title: "Preise für Jugendspiel-Streaming — Match Live TV"
|
||||||
meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube."
|
meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube."
|
||||||
@@ -35,8 +83,8 @@ de:
|
|||||||
club_info_html: "Verein: <strong>%{name}</strong> · %{billing_link}"
|
club_info_html: "Verein: <strong>%{name}</strong> · %{billing_link}"
|
||||||
manage_subscription: Abo verwalten
|
manage_subscription: Abo verwalten
|
||||||
team_title: Pläne für Ihr Team
|
team_title: Pläne für Ihr Team
|
||||||
team_lead: "Jahresabo für Vereine: die Streaming-Verantwortlichen starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
|
team_lead: "Jahresabo für Vereine: die Streaming-Konten starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
|
||||||
table_staff: Streaming-Verantwortliche / Jahr
|
table_staff: Streaming-Konten
|
||||||
table_matches: Gleichzeitige Spiele
|
table_matches: Gleichzeitige Spiele
|
||||||
table_live_mltv: Live auf Match Live TV
|
table_live_mltv: Live auf Match Live TV
|
||||||
table_youtube: YouTube
|
table_youtube: YouTube
|
||||||
@@ -44,8 +92,7 @@ de:
|
|||||||
table_download: Handy-Download
|
table_download: Handy-Download
|
||||||
table_price: Preis
|
table_price: Preis
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_light_html: "€40/Jahr oder €5/Monat"
|
table_price_note: "Listenpreis %{list} — %{monthly}"
|
||||||
table_price_full_html: "€200/Jahr oder €20/Monat"
|
|
||||||
table_yes: Ja
|
table_yes: Ja
|
||||||
table_no: Nein
|
table_no: Nein
|
||||||
table_unlimited: Unbegrenzt
|
table_unlimited: Unbegrenzt
|
||||||
@@ -55,8 +102,16 @@ de:
|
|||||||
contact_button: Kontaktieren Sie uns
|
contact_button: Kontaktieren Sie uns
|
||||||
plans:
|
plans:
|
||||||
price_alt_html: "oder <strong>%{price}</strong>"
|
price_alt_html: "oder <strong>%{price}</strong>"
|
||||||
staff_html: "Streaming-Verantwortliche: <strong>%{count}</strong> / Jahr pro Team"
|
price_equiv: "entspricht %{price}"
|
||||||
|
launch_price: Einführungspreis
|
||||||
|
recommended_badge: Empfohlen für Vereine
|
||||||
|
cta_name:
|
||||||
|
premium_light: Light
|
||||||
|
premium_full: Full
|
||||||
|
staff_html: "Streaming-Konten: maximal <strong>%{count}</strong> pro Abonnement"
|
||||||
|
staff_unlimited_html: "Streaming-Konten: <strong>unbegrenzt</strong> pro Abonnement"
|
||||||
staff_unlimited: unbegrenzt
|
staff_unlimited: unbegrenzt
|
||||||
|
staff_note_full: "Keine geteilten Passwörter: jede Person nutzt den eigenen Zugang, jederzeit widerrufbar."
|
||||||
matches_html: "Spiele: <strong>%{count}</strong> gleichzeitig (ganzer Verein)"
|
matches_html: "Spiele: <strong>%{count}</strong> gleichzeitig (ganzer Verein)"
|
||||||
matches_unlimited: unbegrenzt
|
matches_unlimited: unbegrenzt
|
||||||
replay_html: "Replay: %{value}"
|
replay_html: "Replay: %{value}"
|
||||||
|
|||||||
@@ -2,32 +2,80 @@ en:
|
|||||||
pages:
|
pages:
|
||||||
features:
|
features:
|
||||||
meta_title: "Youth match streaming features — Match Live TV"
|
meta_title: "Youth match streaming features — Match Live TV"
|
||||||
meta_description: "App for live streaming from your phone: film the match, update the score, share the link with parents and grandparents. Match archive and plans for youth sports clubs."
|
meta_description: "Stream your club's matches from a smartphone with personal accounts, score via link, live on Match Live TV and YouTube, plus replay and archive. No complicated equipment."
|
||||||
title: Features
|
hero_title: "Your club's live streams. No shared passwords, no complicated gear."
|
||||||
lead: "Everything you need so people who can't be at the gym can follow the match: simple for coaches and volunteers, convenient for parents, grandparents and relatives."
|
hero_lead: "Invite coaches, managers and parents to stream with their own account. All you need is a smartphone: Match Live TV handles access, scoring, the live stream, replay and publishing."
|
||||||
staff_section_title: For the club and staff
|
hero_alt: Smartphone on a tripod for the live stream and a second phone for the gym scoreboard
|
||||||
staff_invite_title: Invites with their own email
|
flow_aria: Streaming flow
|
||||||
staff_invite_body: "The president or owner invites whoever films and whoever updates the score: each person signs in with their own email, no shared passwords."
|
flow_phone: One smartphone
|
||||||
staff_access_title: Access management
|
flow_account: One personal account
|
||||||
staff_access_body: You can remove a volunteer's access or cancel a pending invite from the team dashboard.
|
flow_live: You're live
|
||||||
staff_plans_title: Plans to fit your needs
|
how_title: From the gym to viewers in a few minutes
|
||||||
staff_plans_body: With Free, Premium Light or Premium Full you increase how many people can help stream live and how many matches can run in parallel.
|
how_1_title: Create the match
|
||||||
live_section_title: On the court and live
|
how_1_body: The club sets up the match with team, opponent, date and time.
|
||||||
live_phone_title: Film from your phone
|
how_2_title: Open the app
|
||||||
live_phone_body: "Prop up the phone and start the stream: even with weak 4G the broadcast stays as stable as possible, with no cables or mixers to worry about."
|
how_2_body: The assigned person signs into the Match Live TV app with their own personal account.
|
||||||
live_score_title: Score from a second phone
|
how_3_title: Start the stream
|
||||||
live_score_body: "Another volunteer updates sets and points from a second phone: just scan the code shown on the streamer's screen."
|
how_3_body: A smartphone is enough. No dedicated equipment or complicated setup.
|
||||||
live_link_title: Link for families and fans
|
how_4_title: Manage the score
|
||||||
live_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install."
|
how_4_body: Someone else can update the result and score through a simple link, without installing an app or creating an account.
|
||||||
live_archive_title: Match archive
|
how_5_title: Replay
|
||||||
live_archive_body: "With Premium Light or Full, matches stay saved on the server (30 or 90 days) and you can download them to your phone whenever you need."
|
how_5_body: After the stream ends, the match can be kept and made available as a replay, depending on the plan.
|
||||||
live_youtube_mltv_title: Also on YouTube (Match Live TV)
|
accounts_title: Everyone with their own account. The club password stays with the club.
|
||||||
live_youtube_mltv_body: With Premium Light the stream is also visible on the Match Live TV YouTube channel, in addition to the link on the site.
|
accounts_lead: You no longer need to share the main club username and password. Managers, coaches and assigned parents get a personal, revocable login.
|
||||||
live_youtube_club_title: Your club's YouTube channel
|
accounts_invite_title: Invite
|
||||||
live_youtube_club_body: With Premium Full you can send the same stream to your club's official YouTube channel.
|
accounts_invite_body: The club invites coaches, managers or parents assigned to stream.
|
||||||
live_website_title: On the club's website
|
accounts_transmit_title: Stream
|
||||||
live_website_body: Integration to embed the player on the club's website — coming with Premium Full.
|
accounts_transmit_body: Each person signs into the app with their own credentials.
|
||||||
cta_signup: Register your team
|
accounts_revoke_title: Revoke
|
||||||
|
accounts_revoke_body: When someone no longer works with the team or club, their access can be revoked without changing everyone else's password.
|
||||||
|
accounts_highlight: No shared passwords. Every assigned person has their own personal access.
|
||||||
|
score_title: Who films, films. Who keeps score, keeps score.
|
||||||
|
score_lead: The person streaming can focus on the framing. Someone else, even from the stands, can open a simple link and update the score in real time.
|
||||||
|
score_item_app: No app to install
|
||||||
|
score_item_signup: No sign-up
|
||||||
|
score_item_password: No password
|
||||||
|
score_item_link: Just open the shared link
|
||||||
|
score_card_aria: Score control access
|
||||||
|
score_card_title: Score control via a link
|
||||||
|
score_card_body: Whoever updates sets and points opens a shared link. No account and no invite.
|
||||||
|
access_link: Open with a link
|
||||||
|
distribute_title: One match, more ways to follow it
|
||||||
|
distribute_lead: From the smartphone in the gym, the stream reaches viewers on Match Live TV and, when included in the plan, also on YouTube.
|
||||||
|
distribute_aria: Where the stream goes
|
||||||
|
distribute_phone: Smartphone
|
||||||
|
distribute_mltv: Match Live TV
|
||||||
|
distribute_viewers: Viewers
|
||||||
|
distribute_youtube: YouTube
|
||||||
|
youtube_badge: Premium Full
|
||||||
|
youtube_title: The stream stays with your club
|
||||||
|
youtube_body: With Premium Full you can also stream to the club's official YouTube channel. The club keeps its audience, subscribers, views, match archive and sponsor visibility.
|
||||||
|
youtube_mock_channel: Official club channel
|
||||||
|
youtube_mock_subs: Club subscribers and views
|
||||||
|
replay_title: The match ends. The memory stays.
|
||||||
|
replay_lead: On plans that include replay, matches are stored on Match Live TV servers and can be watched later. How long they are kept depends on the plan.
|
||||||
|
replay_item_server: Stored on Match Live TV servers
|
||||||
|
replay_item_plan: Archive length based on the plan
|
||||||
|
replay_item_download: Download the video to your phone when you need it
|
||||||
|
replay_card_title: Match archive
|
||||||
|
replay_card_body: Rewatch matches after the final whistle and download them when you need them.
|
||||||
|
replay_mock_1_title: Rossi vs Neri
|
||||||
|
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||||
|
replay_mock_2_title: Rossi vs Blu
|
||||||
|
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||||
|
replay_mock_3_title: Rossi vs Bianchi
|
||||||
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
|
more_title: Also part of the product
|
||||||
|
more_stable_title: Stream from your smartphone
|
||||||
|
more_stable_body: Prop up the phone and start streaming. Even with weak 4G the broadcast stays as stable as possible, with no cables or mixers.
|
||||||
|
more_link_title: Link for families and fans
|
||||||
|
more_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install."
|
||||||
|
more_yt_mltv_title: Also on Match Live TV YouTube
|
||||||
|
more_yt_mltv_body: You can also make the stream visible on the Match Live TV YouTube channel, in addition to the link on the site.
|
||||||
|
cta_title: Ready for the next match?
|
||||||
|
cta_body: Try Match Live TV for free. No dedicated equipment needed.
|
||||||
|
cta_primary: Start for free
|
||||||
|
cta_secondary: Compare plans
|
||||||
pricing:
|
pricing:
|
||||||
meta_title: "Youth match streaming pricing — Match Live TV"
|
meta_title: "Youth match streaming pricing — Match Live TV"
|
||||||
meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube."
|
meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube."
|
||||||
@@ -35,8 +83,8 @@ en:
|
|||||||
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
|
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
|
||||||
manage_subscription: Manage subscription
|
manage_subscription: Manage subscription
|
||||||
team_title: Plans for your team
|
team_title: Plans for your team
|
||||||
team_lead: "Annual subscription for clubs: streaming staff start the broadcast from the app; the score is managed with a shareable link."
|
team_lead: "Annual subscription for clubs: transmitter accounts start the broadcast from the app; the score is managed with a shareable link."
|
||||||
table_staff: Streaming staff / year
|
table_staff: Transmitter accounts
|
||||||
table_matches: Concurrent matches
|
table_matches: Concurrent matches
|
||||||
table_live_mltv: Live on Match Live TV
|
table_live_mltv: Live on Match Live TV
|
||||||
table_youtube: YouTube
|
table_youtube: YouTube
|
||||||
@@ -44,8 +92,7 @@ en:
|
|||||||
table_download: Phone download
|
table_download: Phone download
|
||||||
table_price: Price
|
table_price: Price
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_light_html: "€40/year or €5/month"
|
table_price_note: "list %{list} — %{monthly}"
|
||||||
table_price_full_html: "€200/year or €20/month"
|
|
||||||
table_yes: "Yes"
|
table_yes: "Yes"
|
||||||
table_no: "No"
|
table_no: "No"
|
||||||
table_unlimited: Unlimited
|
table_unlimited: Unlimited
|
||||||
@@ -55,8 +102,16 @@ en:
|
|||||||
contact_button: Contact us
|
contact_button: Contact us
|
||||||
plans:
|
plans:
|
||||||
price_alt_html: "or <strong>%{price}</strong>"
|
price_alt_html: "or <strong>%{price}</strong>"
|
||||||
staff_html: "Streaming staff: <strong>%{count}</strong> / year per team"
|
price_equiv: "equivalent to %{price}"
|
||||||
|
launch_price: Launch price
|
||||||
|
recommended_badge: Recommended for clubs
|
||||||
|
cta_name:
|
||||||
|
premium_light: Light
|
||||||
|
premium_full: Full
|
||||||
|
staff_html: "Transmitter accounts: max <strong>%{count}</strong> per subscription"
|
||||||
|
staff_unlimited_html: "Transmitter accounts: <strong>unlimited</strong> per subscription"
|
||||||
staff_unlimited: unlimited
|
staff_unlimited: unlimited
|
||||||
|
staff_note_full: "No shared passwords: each person uses their own login, which can be revoked at any time."
|
||||||
matches_html: "Matches: <strong>%{count}</strong> concurrent (whole club)"
|
matches_html: "Matches: <strong>%{count}</strong> concurrent (whole club)"
|
||||||
matches_unlimited: unlimited
|
matches_unlimited: unlimited
|
||||||
replay_html: "Replay: %{value}"
|
replay_html: "Replay: %{value}"
|
||||||
|
|||||||
@@ -2,32 +2,80 @@ es:
|
|||||||
pages:
|
pages:
|
||||||
features:
|
features:
|
||||||
meta_title: "Funciones de streaming de partidos juveniles — Match Live TV"
|
meta_title: "Funciones de streaming de partidos juveniles — Match Live TV"
|
||||||
meta_description: "App para el directo desde el móvil: graba el partido, actualiza el marcador, comparte el enlace con padres y abuelos. Archivo de partidos y planes para clubes deportivos juveniles."
|
meta_description: "Emite los partidos del club desde el móvil con cuentas personales, marcador por enlace, directo en Match Live TV y YouTube, más repetición y archivo. Sin equipo complicado."
|
||||||
title: Funciones
|
hero_title: "Los directos de tu club. Sin contraseñas compartidas, sin equipo complicado."
|
||||||
lead: "Todo lo necesario para que quienes no pueden estar en el pabellón sigan el partido: sencillo para entrenadores y voluntarios, cómodo para padres, abuelos y familiares."
|
hero_lead: "Invita a entrenadores, directivos y padres a emitir con su propia cuenta. Basta un smartphone: Match Live TV gestiona accesos, marcador, directo, repetición y publicación."
|
||||||
staff_section_title: Para el club y el staff
|
hero_alt: Smartphone en trípode para el directo y segundo móvil para el marcador en el pabellón
|
||||||
staff_invite_title: Invitaciones con su propio email
|
flow_aria: Flujo de la emisión
|
||||||
staff_invite_body: "El presidente o propietario invita a quien graba y a quien actualiza el marcador: cada uno accede con su propio email, sin compartir contraseñas."
|
flow_phone: Un smartphone
|
||||||
staff_access_title: Gestión de accesos
|
flow_account: Una cuenta personal
|
||||||
staff_access_body: Puedes retirar el acceso a un voluntario o cancelar una invitación pendiente desde el panel del equipo.
|
flow_live: Estás en directo
|
||||||
staff_plans_title: Planes a medida
|
how_title: Del pabellón a los espectadores en pocos minutos
|
||||||
staff_plans_body: Con Free, Premium Light o Premium Full aumentas cuántas personas pueden ayudar en directo y cuántos partidos pueden ir en paralelo.
|
how_1_title: Crea el partido
|
||||||
live_section_title: En la cancha y en directo
|
how_1_body: El club prepara el partido indicando equipo, rival, fecha y hora.
|
||||||
live_phone_title: Graba desde el móvil
|
how_2_title: Abre la app
|
||||||
live_phone_body: "Apoyas el móvil e inicias el directo: incluso con 4G débil, la transmisión se mantiene lo más estable posible, sin líos de cables ni mezcladores."
|
how_2_body: La persona encargada accede a la app Match Live TV con su propia cuenta personal.
|
||||||
live_score_title: Marcador desde un segundo móvil
|
how_3_title: Empieza el directo
|
||||||
live_score_body: "Otro voluntario actualiza sets y puntos desde un segundo móvil: solo hay que escanear el código que aparece en la pantalla de quien graba."
|
how_3_body: Basta el smartphone. Sin equipo dedicado ni configuración complicada.
|
||||||
live_link_title: Enlace para familias y aficionados
|
how_4_title: Gestiona el marcador
|
||||||
live_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps."
|
how_4_body: Otra persona puede actualizar resultado y marcador con un enlace sencillo, sin instalar apps ni crear una cuenta.
|
||||||
live_archive_title: Archivo de partidos
|
how_5_title: Repetición
|
||||||
live_archive_body: "Con Premium Light o Full los partidos quedan guardados en el servidor (30 o 90 días) y puedes descargarlos al móvil cuando quieras."
|
how_5_body: Al terminar el directo, el partido puede conservarse y quedar disponible como repetición, según el plan elegido.
|
||||||
live_youtube_mltv_title: También en YouTube (Match Live TV)
|
accounts_title: Cada uno con su propia cuenta. La contraseña del club se queda en el club.
|
||||||
live_youtube_mltv_body: "Con Premium Light el directo también se ve en el canal de YouTube de Match Live TV, además del enlace en el sitio."
|
accounts_lead: Ya no hace falta compartir el usuario y la contraseña de la cuenta principal. Directivos, entrenadores y padres encargados reciben un acceso personal y revocable.
|
||||||
live_youtube_club_title: Canal de YouTube del club
|
accounts_invite_title: Invita
|
||||||
live_youtube_club_body: "Con Premium Full puedes enviar el mismo directo también al canal oficial de YouTube del club."
|
accounts_invite_body: El club invita a entrenadores, directivos o padres encargados de la emisión.
|
||||||
live_website_title: En el sitio web del club
|
accounts_transmit_title: Emite
|
||||||
live_website_body: "Integración para incrustar el reproductor en el sitio web del club — próximamente con Premium Full."
|
accounts_transmit_body: Cada uno accede a la app con sus propias credenciales.
|
||||||
cta_signup: Registra tu equipo
|
accounts_revoke_title: Revoca
|
||||||
|
accounts_revoke_body: Cuando una persona deja de colaborar con el equipo o el club, su acceso puede revocarse sin cambiar la contraseña a los demás.
|
||||||
|
accounts_highlight: Sin contraseñas compartidas. Cada encargado tiene su propio acceso personal.
|
||||||
|
score_title: Quien graba, graba. Quien lleva el marcador, lleva el marcador.
|
||||||
|
score_lead: Quien emite puede centrarse en el encuadre. Otra persona, incluso desde la grada, puede abrir un enlace sencillo y actualizar el marcador en tiempo real.
|
||||||
|
score_item_app: Sin app que instalar
|
||||||
|
score_item_signup: Sin registro
|
||||||
|
score_item_password: Sin contraseña
|
||||||
|
score_item_link: Basta abrir el enlace compartido
|
||||||
|
score_card_aria: Acceso a la regia del marcador
|
||||||
|
score_card_title: Regia con un enlace
|
||||||
|
score_card_body: Quien actualiza sets y puntos abre un enlace compartido. Sin cuenta y sin invitación.
|
||||||
|
access_link: Acceso con enlace
|
||||||
|
distribute_title: Un partido, más formas de seguirlo
|
||||||
|
distribute_lead: Desde el smartphone en el pabellón, el directo llega a los espectadores en Match Live TV y, según el plan, también a YouTube.
|
||||||
|
distribute_aria: Dónde llega el directo
|
||||||
|
distribute_phone: Smartphone
|
||||||
|
distribute_mltv: Match Live TV
|
||||||
|
distribute_viewers: Espectadores
|
||||||
|
distribute_youtube: YouTube
|
||||||
|
youtube_badge: Premium Full
|
||||||
|
youtube_title: El directo se queda en tu club
|
||||||
|
youtube_body: Con Premium Full también puedes emitir en el canal oficial de YouTube del club. Así el club conserva su público, suscriptores, visualizaciones, archivo de partidos y visibilidad de patrocinadores.
|
||||||
|
youtube_mock_channel: Canal oficial del club
|
||||||
|
youtube_mock_subs: Suscriptores y visualizaciones del club
|
||||||
|
replay_title: El partido termina. El recuerdo permanece.
|
||||||
|
replay_lead: En los planes con repetición, los partidos se guardan en los servidores de Match Live TV y pueden verse después. La duración del archivo depende del plan.
|
||||||
|
replay_item_server: Conservación en servidores Match Live TV
|
||||||
|
replay_item_plan: Duración del archivo según el plan
|
||||||
|
replay_item_download: Descarga del vídeo al móvil cuando haga falta
|
||||||
|
replay_card_title: Archivo de partidos
|
||||||
|
replay_card_body: Vuelve a ver los partidos tras el pitido final y descárgalos cuando los necesites.
|
||||||
|
replay_mock_1_title: Rossi vs Neri
|
||||||
|
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||||
|
replay_mock_2_title: Rossi vs Blu
|
||||||
|
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||||
|
replay_mock_3_title: Rossi vs Bianchi
|
||||||
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
|
more_title: También forma parte del producto
|
||||||
|
more_stable_title: Directo desde el smartphone
|
||||||
|
more_stable_body: Apoyas el móvil e inicias la emisión. Incluso con 4G débil, el directo se mantiene lo más estable posible, sin cables ni mezcladores.
|
||||||
|
more_link_title: Enlace para familias y aficionados
|
||||||
|
more_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps."
|
||||||
|
more_yt_mltv_title: También en YouTube Match Live TV
|
||||||
|
more_yt_mltv_body: También puedes hacer visible el directo en el canal de YouTube de Match Live TV, además del enlace en el sitio.
|
||||||
|
cta_title: ¿Listos para el próximo partido?
|
||||||
|
cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado.
|
||||||
|
cta_primary: Empieza gratis
|
||||||
|
cta_secondary: Compara los planes
|
||||||
pricing:
|
pricing:
|
||||||
meta_title: "Precios de streaming de partidos juveniles — Match Live TV"
|
meta_title: "Precios de streaming de partidos juveniles — Match Live TV"
|
||||||
meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube."
|
meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube."
|
||||||
@@ -35,8 +83,8 @@ es:
|
|||||||
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
|
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
|
||||||
manage_subscription: Gestionar suscripción
|
manage_subscription: Gestionar suscripción
|
||||||
team_title: Planes para tu equipo
|
team_title: Planes para tu equipo
|
||||||
team_lead: "Suscripción anual para clubes: los responsables de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir."
|
team_lead: "Suscripción anual para clubes: las cuentas de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir."
|
||||||
table_staff: Responsables de emisión / año
|
table_staff: Cuentas de emisión
|
||||||
table_matches: Partidos simultáneos
|
table_matches: Partidos simultáneos
|
||||||
table_live_mltv: Directo en Match Live TV
|
table_live_mltv: Directo en Match Live TV
|
||||||
table_youtube: YouTube
|
table_youtube: YouTube
|
||||||
@@ -44,8 +92,7 @@ es:
|
|||||||
table_download: Descarga al móvil
|
table_download: Descarga al móvil
|
||||||
table_price: Precio
|
table_price: Precio
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_light_html: "€40/año o €5/mes"
|
table_price_note: "tarifa %{list} — %{monthly}"
|
||||||
table_price_full_html: "€200/año o €20/mes"
|
|
||||||
table_yes: "Sí"
|
table_yes: "Sí"
|
||||||
table_no: "No"
|
table_no: "No"
|
||||||
table_unlimited: Ilimitado
|
table_unlimited: Ilimitado
|
||||||
@@ -55,8 +102,16 @@ es:
|
|||||||
contact_button: Contáctanos
|
contact_button: Contáctanos
|
||||||
plans:
|
plans:
|
||||||
price_alt_html: "o <strong>%{price}</strong>"
|
price_alt_html: "o <strong>%{price}</strong>"
|
||||||
staff_html: "Responsables de emisión: <strong>%{count}</strong> / año por equipo"
|
price_equiv: "equivale a %{price}"
|
||||||
staff_unlimited: ilimitado
|
launch_price: Precio de lanzamiento
|
||||||
|
recommended_badge: Recomendado para clubes
|
||||||
|
cta_name:
|
||||||
|
premium_light: Light
|
||||||
|
premium_full: Full
|
||||||
|
staff_html: "Cuentas de emisión: máximo <strong>%{count}</strong> por suscripción"
|
||||||
|
staff_unlimited_html: "Cuentas de emisión: <strong>ilimitadas</strong> por suscripción"
|
||||||
|
staff_unlimited: ilimitadas
|
||||||
|
staff_note_full: "Sin contraseñas compartidas: cada persona usa su propio acceso, revocable en cualquier momento."
|
||||||
matches_html: "Partidos: <strong>%{count}</strong> simultáneos (todo el club)"
|
matches_html: "Partidos: <strong>%{count}</strong> simultáneos (todo el club)"
|
||||||
matches_unlimited: ilimitados
|
matches_unlimited: ilimitados
|
||||||
replay_html: "Repetición: %{value}"
|
replay_html: "Repetición: %{value}"
|
||||||
|
|||||||
@@ -2,32 +2,80 @@ fr:
|
|||||||
pages:
|
pages:
|
||||||
features:
|
features:
|
||||||
meta_title: "Fonctionnalités de streaming des matchs jeunes — Match Live TV"
|
meta_title: "Fonctionnalités de streaming des matchs jeunes — Match Live TV"
|
||||||
meta_description: "Application pour le direct depuis le téléphone : filmez le match, mettez à jour le score, partagez le lien avec parents et grands-parents. Archive des matchs et offres pour les clubs sportifs jeunes."
|
meta_description: "Diffusez les matchs du club depuis un smartphone avec des comptes personnels, le score via un lien, le direct sur Match Live TV et YouTube, plus le replay et l'archive. Sans matériel compliqué."
|
||||||
title: Fonctionnalités
|
hero_title: "Les directs de votre club. Sans mots de passe partagés, sans matériel compliqué."
|
||||||
lead: "Tout ce qu'il faut pour faire suivre les matchs à ceux qui ne peuvent pas être en salle : simple pour les coachs et bénévoles, pratique pour parents, grands-parents et proches."
|
hero_lead: "Invitez entraîneurs, dirigeants et parents à diffuser avec leur propre compte. Un smartphone suffit : Match Live TV gère les accès, le score, le direct, le replay et la publication."
|
||||||
staff_section_title: Pour le club et le staff
|
hero_alt: Smartphone sur trépied pour le direct et second téléphone pour le score en salle
|
||||||
staff_invite_title: Invitations avec leur propre email
|
flow_aria: Déroulement de la diffusion
|
||||||
staff_invite_body: "Le président ou le propriétaire invite qui filme et qui met à jour le score : chacun se connecte avec son propre email, sans partager de mot de passe."
|
flow_phone: Un smartphone
|
||||||
staff_access_title: Gestion des accès
|
flow_account: Un compte personnel
|
||||||
staff_access_body: Vous pouvez retirer l'accès à un bénévole ou annuler une invitation encore en attente depuis le tableau de bord de l'équipe.
|
flow_live: Vous êtes en direct
|
||||||
staff_plans_title: Des offres sur mesure
|
how_title: De la salle aux spectateurs en quelques minutes
|
||||||
staff_plans_body: "Avec Free, Premium Light ou Premium Full, augmentez le nombre de personnes qui peuvent aider en direct et le nombre de matchs en parallèle."
|
how_1_title: Créez le match
|
||||||
live_section_title: Sur le terrain et en direct
|
how_1_body: Le club prépare le match en indiquant l'équipe, l'adversaire, la date et l'heure.
|
||||||
live_phone_title: Filmez depuis votre téléphone
|
how_2_title: Ouvrez l'application
|
||||||
live_phone_body: "Posez le téléphone et lancez le direct : même avec un 4G faible, la diffusion reste aussi stable que possible, sans câbles ni mixeur."
|
how_2_body: La personne désignée se connecte à l'application Match Live TV avec son propre compte personnel.
|
||||||
live_score_title: Score depuis un second téléphone
|
how_3_title: Lancez le direct
|
||||||
live_score_body: "Un autre bénévole met à jour les sets et les points depuis un second téléphone : il suffit de scanner le code affiché sur l'écran de celui qui filme."
|
how_3_body: Un smartphone suffit. Pas de matériel dédié ni de configuration compliquée.
|
||||||
live_link_title: Lien pour les familles et supporters
|
how_4_title: Gérez le score
|
||||||
live_link_body: "Partagez un lien : parents, grands-parents et amis regardent depuis le navigateur du téléphone ou de l'ordinateur, sans installer d'application."
|
how_4_body: Une autre personne peut mettre à jour le résultat et le score via un simple lien, sans installer d'application ni créer de compte.
|
||||||
live_archive_title: Archive des matchs
|
how_5_title: Replay
|
||||||
live_archive_body: "Avec Premium Light ou Full, les matchs restent enregistrés sur le serveur (30 ou 90 jours) et vous pouvez les télécharger sur le téléphone quand vous voulez."
|
how_5_body: À la fin du direct, le match peut être conservé et rendu disponible en replay, selon l'offre choisie.
|
||||||
live_youtube_mltv_title: Aussi sur YouTube (Match Live TV)
|
accounts_title: Chacun avec son propre compte. Le mot de passe du club reste au club.
|
||||||
live_youtube_mltv_body: "Avec Premium Light, le direct est aussi visible sur la chaîne YouTube Match Live TV, en plus du lien sur le site."
|
accounts_lead: Plus besoin de partager l'identifiant et le mot de passe du compte principal. Dirigeants, entraîneurs et parents désignés reçoivent un accès personnel et révocable.
|
||||||
live_youtube_club_title: Chaîne YouTube du club
|
accounts_invite_title: Invitez
|
||||||
live_youtube_club_body: "Avec Premium Full, vous pouvez diffuser le même direct sur la chaîne YouTube officielle du club."
|
accounts_invite_body: Le club invite les entraîneurs, dirigeants ou parents chargés de la diffusion.
|
||||||
live_website_title: Sur le site du club
|
accounts_transmit_title: Diffusez
|
||||||
live_website_body: "Intégration pour intégrer le lecteur sur le site du club — bientôt disponible avec Premium Full."
|
accounts_transmit_body: Chacun se connecte à l'application avec ses propres identifiants.
|
||||||
cta_signup: Inscrire l'équipe
|
accounts_revoke_title: Révoquez
|
||||||
|
accounts_revoke_body: Quand une personne ne collabore plus avec l'équipe ou le club, son accès peut être révoqué sans changer le mot de passe des autres.
|
||||||
|
accounts_highlight: Pas de mots de passe partagés. Chaque personne désignée a son propre accès personnel.
|
||||||
|
score_title: Qui filme, filme. Qui tient le score, tient le score.
|
||||||
|
score_lead: La personne qui diffuse peut se concentrer sur le cadrage. Une autre, même depuis les tribunes, peut ouvrir un simple lien et mettre à jour le score en temps réel.
|
||||||
|
score_item_app: Aucune application à installer
|
||||||
|
score_item_signup: Aucune inscription
|
||||||
|
score_item_password: Aucun mot de passe
|
||||||
|
score_item_link: Il suffit d'ouvrir le lien partagé
|
||||||
|
score_card_aria: Accès à la régie du score
|
||||||
|
score_card_title: Régie via un lien
|
||||||
|
score_card_body: Celui qui met à jour sets et points ouvre un lien partagé. Aucun compte, aucune invitation.
|
||||||
|
access_link: Accès par lien
|
||||||
|
distribute_title: Un match, plusieurs façons de le suivre
|
||||||
|
distribute_lead: Depuis le smartphone en salle, le direct arrive aux spectateurs sur Match Live TV et, selon l'offre, aussi sur YouTube.
|
||||||
|
distribute_aria: Où va le direct
|
||||||
|
distribute_phone: Smartphone
|
||||||
|
distribute_mltv: Match Live TV
|
||||||
|
distribute_viewers: Spectateurs
|
||||||
|
distribute_youtube: YouTube
|
||||||
|
youtube_badge: Premium Full
|
||||||
|
youtube_title: Le direct reste celui de votre club
|
||||||
|
youtube_body: Avec Premium Full, vous pouvez aussi diffuser sur la chaîne YouTube officielle du club. Le club conserve ainsi son public, ses abonnés, ses vues, l'archive des matchs et la visibilité des sponsors.
|
||||||
|
youtube_mock_channel: Chaîne officielle du club
|
||||||
|
youtube_mock_subs: Abonnés et vues du club
|
||||||
|
replay_title: Le match se termine. Le souvenir reste.
|
||||||
|
replay_lead: Sur les offres qui incluent le replay, les matchs restent enregistrés sur les serveurs Match Live TV et peuvent être revus ensuite. La durée de conservation dépend de l'offre.
|
||||||
|
replay_item_server: Conservation sur les serveurs Match Live TV
|
||||||
|
replay_item_plan: Durée d'archive selon l'offre
|
||||||
|
replay_item_download: Téléchargement de la vidéo sur le téléphone si besoin
|
||||||
|
replay_card_title: Archive des matchs
|
||||||
|
replay_card_body: Revoyez les matchs après le coup de sifflet final et téléchargez-les quand vous en avez besoin.
|
||||||
|
replay_mock_1_title: Rossi vs Neri
|
||||||
|
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||||
|
replay_mock_2_title: Rossi vs Blu
|
||||||
|
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||||
|
replay_mock_3_title: Rossi vs Bianchi
|
||||||
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
|
more_title: Cela fait aussi partie du produit
|
||||||
|
more_stable_title: Direct depuis le smartphone
|
||||||
|
more_stable_body: Posez le téléphone et lancez la diffusion. Même avec un 4G faible, le direct reste aussi stable que possible, sans câbles ni mixeur.
|
||||||
|
more_link_title: Lien pour familles et supporters
|
||||||
|
more_link_body: "Partagez un lien : parents, grands-parents et amis regardent depuis le navigateur du téléphone ou de l'ordinateur, sans installer d'application."
|
||||||
|
more_yt_mltv_title: Aussi sur YouTube Match Live TV
|
||||||
|
more_yt_mltv_body: Vous pouvez aussi rendre le direct visible sur la chaîne YouTube Match Live TV, en plus du lien sur le site.
|
||||||
|
cta_title: Prêts pour le prochain match ?
|
||||||
|
cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire.
|
||||||
|
cta_primary: Commencer gratuitement
|
||||||
|
cta_secondary: Comparer les offres
|
||||||
pricing:
|
pricing:
|
||||||
meta_title: "Tarifs streaming des matchs jeunes — Match Live TV"
|
meta_title: "Tarifs streaming des matchs jeunes — Match Live TV"
|
||||||
meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube."
|
meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube."
|
||||||
@@ -35,8 +83,8 @@ fr:
|
|||||||
club_info_html: "Club : <strong>%{name}</strong> · %{billing_link}"
|
club_info_html: "Club : <strong>%{name}</strong> · %{billing_link}"
|
||||||
manage_subscription: Gérer l'abonnement
|
manage_subscription: Gérer l'abonnement
|
||||||
team_title: Offres pour votre équipe
|
team_title: Offres pour votre équipe
|
||||||
team_lead: "Abonnement annuel pour les clubs : les responsables de diffusion démarrent le direct depuis l'application ; le score se gère avec un lien à partager."
|
team_lead: "Abonnement annuel pour les clubs : les comptes diffuseurs démarrent le direct depuis l'application ; le score se gère avec un lien à partager."
|
||||||
table_staff: Responsables de diffusion / an
|
table_staff: Comptes diffuseurs
|
||||||
table_matches: Matchs simultanés
|
table_matches: Matchs simultanés
|
||||||
table_live_mltv: Direct sur Match Live TV
|
table_live_mltv: Direct sur Match Live TV
|
||||||
table_youtube: YouTube
|
table_youtube: YouTube
|
||||||
@@ -44,8 +92,7 @@ fr:
|
|||||||
table_download: Téléchargement téléphone
|
table_download: Téléchargement téléphone
|
||||||
table_price: Prix
|
table_price: Prix
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_light_html: "€40/an ou €5/mois"
|
table_price_note: "tarif %{list} — %{monthly}"
|
||||||
table_price_full_html: "€200/an ou €20/mois"
|
|
||||||
table_yes: Oui
|
table_yes: Oui
|
||||||
table_no: Non
|
table_no: Non
|
||||||
table_unlimited: Illimité
|
table_unlimited: Illimité
|
||||||
@@ -55,8 +102,16 @@ fr:
|
|||||||
contact_button: Contactez-nous
|
contact_button: Contactez-nous
|
||||||
plans:
|
plans:
|
||||||
price_alt_html: "ou <strong>%{price}</strong>"
|
price_alt_html: "ou <strong>%{price}</strong>"
|
||||||
staff_html: "Responsables de diffusion : <strong>%{count}</strong> / an par équipe"
|
price_equiv: "soit %{price}"
|
||||||
staff_unlimited: illimité
|
launch_price: Prix de lancement
|
||||||
|
recommended_badge: Conseillé pour les clubs
|
||||||
|
cta_name:
|
||||||
|
premium_light: Light
|
||||||
|
premium_full: Full
|
||||||
|
staff_html: "Comptes diffuseurs : maximum <strong>%{count}</strong> par abonnement"
|
||||||
|
staff_unlimited_html: "Comptes diffuseurs : <strong>illimités</strong> par abonnement"
|
||||||
|
staff_unlimited: illimités
|
||||||
|
staff_note_full: "Pas de mot de passe partagé : chaque personne utilise son propre accès, révocable à tout moment."
|
||||||
matches_html: "Matchs : <strong>%{count}</strong> simultanés (tout le club)"
|
matches_html: "Matchs : <strong>%{count}</strong> simultanés (tout le club)"
|
||||||
matches_unlimited: illimités
|
matches_unlimited: illimités
|
||||||
replay_html: "Replay : %{value}"
|
replay_html: "Replay : %{value}"
|
||||||
|
|||||||
@@ -2,32 +2,80 @@ it:
|
|||||||
pages:
|
pages:
|
||||||
features:
|
features:
|
||||||
meta_title: "Funzionalità streaming partite giovanili — Match Live TV"
|
meta_title: "Funzionalità streaming partite giovanili — Match Live TV"
|
||||||
meta_description: "App per diretta live da telefono: filma la partita, aggiorna il punteggio, condividi il link con genitori e nonni. Archivio gare e piani per società sportive giovanili."
|
meta_description: "Trasmetti le partite della società dallo smartphone con account personali, punteggio via link, diretta su Match Live TV e YouTube, replay e archivio. Senza attrezzatura complicata."
|
||||||
title: Funzionalità
|
hero_title: "Le dirette della tua società. Senza password condivise, senza attrezzatura complicata."
|
||||||
lead: "Tutto ciò che serve per far seguire le partite a chi non può essere in palestra: semplice per coach e volontari, comodo per genitori, nonni e parenti."
|
hero_lead: "Invita allenatori, dirigenti e genitori a trasmettere con il proprio account. Basta uno smartphone: Match Live TV gestisce accessi, punteggio, diretta, replay e pubblicazione."
|
||||||
staff_section_title: Per la società e lo staff
|
hero_alt: Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra
|
||||||
staff_invite_title: Inviti con la propria email
|
flow_aria: Flusso della trasmissione
|
||||||
staff_invite_body: "Il presidente o l'owner invita chi filma e chi aggiorna il punteggio: ognuno accede con la sua email, senza condividere password."
|
flow_phone: Uno smartphone
|
||||||
staff_access_title: Gestione accessi
|
flow_account: Un account personale
|
||||||
staff_access_body: Puoi togliere l'accesso a un volontario o annullare un invito ancora in sospeso dalla dashboard della squadra.
|
flow_live: Sei in diretta
|
||||||
staff_plans_title: Piani su misura
|
how_title: Dalla palestra agli spettatori in pochi minuti
|
||||||
staff_plans_body: Con Free, Premium Light o Premium Full aumenti quante persone possono aiutare in diretta e quante partite possono andare in parallelo.
|
how_1_title: Crea la partita
|
||||||
live_section_title: In campo e in diretta
|
how_1_body: La società prepara la partita indicando squadra, avversario, data e ora.
|
||||||
live_phone_title: Filma dal telefono
|
how_2_title: Apri l'app
|
||||||
live_phone_body: "Appoggi il telefono e avvii la diretta: anche con il 4G debole la trasmissione resta stabile il più possibile, senza impazzire con cavi o mixer."
|
how_2_body: L'incaricato accede dall'app Match Live TV con il proprio account personale.
|
||||||
live_score_title: Punteggio da un secondo telefono
|
how_3_title: Inizia la diretta
|
||||||
live_score_body: "Un altro volontario aggiorna set e punti da un secondo cellulare: basta inquadrare il codice che compare sullo schermo di chi filma."
|
how_3_body: Basta lo smartphone. Nessuna attrezzatura dedicata o configurazione complicata.
|
||||||
live_link_title: Link per famiglie e tifosi
|
how_4_title: Gestisci il punteggio
|
||||||
live_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app."
|
how_4_body: Un'altra persona può aggiornare risultato e punteggio tramite un semplice link, senza installare app e senza creare un account.
|
||||||
live_archive_title: Archivio partite
|
how_5_title: Replay
|
||||||
live_archive_body: "Con Premium Light o Full le gare restano salvate sul server (30 o 90 giorni) e puoi scaricarle sul telefono quando serve."
|
how_5_body: Al termine della diretta la partita può essere conservata e resa disponibile come replay, in base al piano scelto.
|
||||||
live_youtube_mltv_title: Anche su YouTube (Match Live TV)
|
accounts_title: Ognuno con il proprio account. La password della società resta alla società.
|
||||||
live_youtube_mltv_body: Con Premium Light la diretta è visibile anche sul canale YouTube Match Live TV, oltre al link sul sito.
|
accounts_lead: Non serve più condividere username e password dell'account principale. Dirigenti, allenatori e genitori incaricati ricevono un accesso personale e revocabile.
|
||||||
live_youtube_club_title: Canale YouTube della società
|
accounts_invite_title: Invita
|
||||||
live_youtube_club_body: Con Premium Full puoi mandare la stessa diretta anche sul canale YouTube ufficiale del club.
|
accounts_invite_body: La società invita allenatori, dirigenti o genitori incaricati della trasmissione.
|
||||||
live_website_title: Sul sito della società
|
accounts_transmit_title: Trasmetti
|
||||||
live_website_body: Integrazione per incorporare il player sul sito del club — in arrivo con Premium Full.
|
accounts_transmit_body: Ognuno accede all'app con le proprie credenziali personali.
|
||||||
cta_signup: Registra la squadra
|
accounts_revoke_title: Revoca
|
||||||
|
accounts_revoke_body: Quando una persona non collabora più con la squadra o con la società, il suo accesso può essere revocato senza cambiare la password agli altri.
|
||||||
|
accounts_highlight: Niente password condivise. Ogni incaricato ha il proprio accesso personale.
|
||||||
|
score_title: Chi filma, filma. Chi tiene il punteggio, tiene il punteggio.
|
||||||
|
score_lead: La persona che trasmette può concentrarsi sull'inquadratura. Un'altra persona, anche dagli spalti, può aprire un semplice link e aggiornare il punteggio in tempo reale.
|
||||||
|
score_item_app: Nessuna app da installare
|
||||||
|
score_item_signup: Nessuna registrazione
|
||||||
|
score_item_password: Nessuna password
|
||||||
|
score_item_link: Basta aprire il link condiviso
|
||||||
|
score_card_aria: Accesso alla regia
|
||||||
|
score_card_title: Regia con un link
|
||||||
|
score_card_body: Chi aggiorna set e punti apre un link condiviso. Nessun account e nessun invito.
|
||||||
|
access_link: Accesso con link
|
||||||
|
distribute_title: Una partita, più modi di seguirla
|
||||||
|
distribute_lead: Dallo smartphone in palestra la diretta arriva agli spettatori su Match Live TV e, quando previsto dal piano, anche su YouTube.
|
||||||
|
distribute_aria: Dove arriva la diretta
|
||||||
|
distribute_phone: Smartphone
|
||||||
|
distribute_mltv: Match Live TV
|
||||||
|
distribute_viewers: Spettatori
|
||||||
|
distribute_youtube: YouTube
|
||||||
|
youtube_badge: Premium Full
|
||||||
|
youtube_title: La diretta resta della tua società
|
||||||
|
youtube_body: Con Premium Full puoi trasmettere anche sul canale YouTube ufficiale del club. La società mantiene così il proprio pubblico, gli iscritti, le visualizzazioni, l'archivio delle partite e la visibilità degli sponsor.
|
||||||
|
youtube_mock_channel: Canale ufficiale società
|
||||||
|
youtube_mock_subs: Iscritti e visualizzazioni del club
|
||||||
|
replay_title: La partita finisce. Il ricordo resta.
|
||||||
|
replay_lead: Nei piani che prevedono il replay, le gare restano salvate sui server Match Live TV e possono essere riviste in seguito. La durata della conservazione dipende dal piano.
|
||||||
|
replay_item_server: Conservazione sui server Match Live TV
|
||||||
|
replay_item_plan: Durata dell'archivio in base al piano
|
||||||
|
replay_item_download: Download del video sul telefono quando serve
|
||||||
|
replay_card_title: Archivio partite
|
||||||
|
replay_card_body: Rivedi le gare dopo il fischio finale e scaricale quando ti servono.
|
||||||
|
replay_mock_1_title: Rossi vs Neri
|
||||||
|
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||||
|
replay_mock_2_title: Rossi vs Blu
|
||||||
|
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||||
|
replay_mock_3_title: Rossi vs Bianchi
|
||||||
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
|
more_title: Anche questo fa parte del prodotto
|
||||||
|
more_stable_title: Diretta dallo smartphone
|
||||||
|
more_stable_body: Appoggi il telefono e avvii la trasmissione. Anche con il 4G debole la diretta resta stabile il più possibile, senza cavi o mixer.
|
||||||
|
more_link_title: Link per famiglie e tifosi
|
||||||
|
more_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app."
|
||||||
|
more_yt_mltv_title: Anche su YouTube Match Live TV
|
||||||
|
more_yt_mltv_body: Puoi rendere la diretta visibile anche sul canale YouTube Match Live TV, oltre al link sul sito.
|
||||||
|
cta_title: Pronti per la prossima partita?
|
||||||
|
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
|
||||||
|
cta_primary: Inizia gratis
|
||||||
|
cta_secondary: Confronta i piani
|
||||||
pricing:
|
pricing:
|
||||||
meta_title: "Prezzi streaming partite giovanili — Match Live TV"
|
meta_title: "Prezzi streaming partite giovanili — Match Live TV"
|
||||||
meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube."
|
meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube."
|
||||||
@@ -35,8 +83,8 @@ it:
|
|||||||
club_info_html: "Società: <strong>%{name}</strong> · %{billing_link}"
|
club_info_html: "Società: <strong>%{name}</strong> · %{billing_link}"
|
||||||
manage_subscription: Gestisci abbonamento
|
manage_subscription: Gestisci abbonamento
|
||||||
team_title: Piani per la tua squadra
|
team_title: Piani per la tua squadra
|
||||||
team_lead: "Abbonamento annuale per società: i responsabili trasmissione avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
|
team_lead: "Abbonamento annuale per società: gli account trasmettitori avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
|
||||||
table_staff: Responsabili trasmissione / anno
|
table_staff: Account trasmettitori
|
||||||
table_matches: Partite in contemporanea
|
table_matches: Partite in contemporanea
|
||||||
table_live_mltv: Live su Match Live TV
|
table_live_mltv: Live su Match Live TV
|
||||||
table_youtube: YouTube
|
table_youtube: YouTube
|
||||||
@@ -44,8 +92,7 @@ it:
|
|||||||
table_download: Download telefono
|
table_download: Download telefono
|
||||||
table_price: Prezzo
|
table_price: Prezzo
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_light_html: "€40/anno o €5/mese"
|
table_price_note: "listino %{list} — %{monthly}"
|
||||||
table_price_full_html: "€200/anno o €20/mese"
|
|
||||||
table_yes: "Sì"
|
table_yes: "Sì"
|
||||||
table_no: "No"
|
table_no: "No"
|
||||||
table_unlimited: Illimitato
|
table_unlimited: Illimitato
|
||||||
@@ -55,8 +102,16 @@ it:
|
|||||||
contact_button: Contattaci
|
contact_button: Contattaci
|
||||||
plans:
|
plans:
|
||||||
price_alt_html: "oppure <strong>%{price}</strong>"
|
price_alt_html: "oppure <strong>%{price}</strong>"
|
||||||
staff_html: "Responsabili trasmissione: <strong>%{count}</strong> / anno per squadra"
|
price_equiv: "equivale a %{price}"
|
||||||
staff_unlimited: illimitato
|
launch_price: Prezzo lancio
|
||||||
|
recommended_badge: Consigliato per le società
|
||||||
|
cta_name:
|
||||||
|
premium_light: Light
|
||||||
|
premium_full: Full
|
||||||
|
staff_html: "Account trasmettitori: massimo <strong>%{count}</strong> per abbonamento"
|
||||||
|
staff_unlimited_html: "Account trasmettitori: <strong>illimitati</strong> per abbonamento"
|
||||||
|
staff_unlimited: illimitati
|
||||||
|
staff_note_full: "Niente password condivise: ogni incaricato utilizza il proprio accesso personale, revocabile in qualsiasi momento."
|
||||||
matches_html: "Partite: <strong>%{count}</strong> in contemporanea (tutta la società)"
|
matches_html: "Partite: <strong>%{count}</strong> in contemporanea (tutta la società)"
|
||||||
matches_unlimited: illimitate
|
matches_unlimited: illimitate
|
||||||
replay_html: "Replay: %{value}"
|
replay_html: "Replay: %{value}"
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ de:
|
|||||||
tagline: "Streamen Sie live vom Smartphone. Großeltern, entfernte Verwandte, Freunde — schauen im Browser, ohne Installation. Und wenn Sie es verpasst haben: es ist im Archiv."
|
tagline: "Streamen Sie live vom Smartphone. Großeltern, entfernte Verwandte, Freunde — schauen im Browser, ohne Installation. Und wenn Sie es verpasst haben: es ist im Archiv."
|
||||||
cta_signup: Team registrieren
|
cta_signup: Team registrieren
|
||||||
cta_live: Live-Spiele ansehen
|
cta_live: Live-Spiele ansehen
|
||||||
|
stores_label: Live gehen vom Smartphone
|
||||||
|
store_app_store_kicker: Laden im
|
||||||
|
store_app_store_name: App Store
|
||||||
|
store_app_store_aria: Match Live TV im App Store laden
|
||||||
|
store_play_kicker: Jetzt bei
|
||||||
|
store_play_name: Google Play
|
||||||
|
store_play_aria: Match Live TV bei Google Play laden
|
||||||
features_aria: Wichtige Vorteile
|
features_aria: Wichtige Vorteile
|
||||||
feature_stable: Stabile Streams
|
feature_stable: Stabile Streams
|
||||||
feature_archive: Sicheres Archiv
|
feature_archive: Sicheres Archiv
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ en:
|
|||||||
tagline: Stream live from your smartphone. Grandparents, distant relatives, friends — they watch in the browser, with nothing to install. And if you missed it, it's in the archive.
|
tagline: Stream live from your smartphone. Grandparents, distant relatives, friends — they watch in the browser, with nothing to install. And if you missed it, it's in the archive.
|
||||||
cta_signup: Register your team
|
cta_signup: Register your team
|
||||||
cta_live: Watch live matches
|
cta_live: Watch live matches
|
||||||
|
stores_label: To go live from your smartphone
|
||||||
|
store_app_store_kicker: Download on the
|
||||||
|
store_app_store_name: App Store
|
||||||
|
store_app_store_aria: Download Match Live TV on the App Store
|
||||||
|
store_play_kicker: Get it on
|
||||||
|
store_play_name: Google Play
|
||||||
|
store_play_aria: Get Match Live TV on Google Play
|
||||||
features_aria: Key benefits
|
features_aria: Key benefits
|
||||||
feature_stable: Reliable streams
|
feature_stable: Reliable streams
|
||||||
feature_archive: Secure archive
|
feature_archive: Secure archive
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ es:
|
|||||||
tagline: Emite en directo desde el smartphone. Abuelos, familiares lejanos, amigos — miran en el navegador, sin instalar nada. Y si te lo perdiste, está en el archivo.
|
tagline: Emite en directo desde el smartphone. Abuelos, familiares lejanos, amigos — miran en el navegador, sin instalar nada. Y si te lo perdiste, está en el archivo.
|
||||||
cta_signup: Registra tu equipo
|
cta_signup: Registra tu equipo
|
||||||
cta_live: Ver directos
|
cta_live: Ver directos
|
||||||
|
stores_label: Para emitir en directo desde el smartphone
|
||||||
|
store_app_store_kicker: Consíguelo en
|
||||||
|
store_app_store_name: App Store
|
||||||
|
store_app_store_aria: Descarga Match Live TV en App Store
|
||||||
|
store_play_kicker: Disponible en
|
||||||
|
store_play_name: Google Play
|
||||||
|
store_play_aria: Descarga Match Live TV en Google Play
|
||||||
features_aria: Ventajas principales
|
features_aria: Ventajas principales
|
||||||
feature_stable: Directos estables
|
feature_stable: Directos estables
|
||||||
feature_archive: Archivo seguro
|
feature_archive: Archivo seguro
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ fr:
|
|||||||
tagline: Diffusez en direct depuis votre smartphone. Les grands-parents, la famille éloignée, les amis — regardent dans le navigateur, sans rien installer. Et si vous l'avez manqué, c'est dans l'archive.
|
tagline: Diffusez en direct depuis votre smartphone. Les grands-parents, la famille éloignée, les amis — regardent dans le navigateur, sans rien installer. Et si vous l'avez manqué, c'est dans l'archive.
|
||||||
cta_signup: Inscrire votre équipe
|
cta_signup: Inscrire votre équipe
|
||||||
cta_live: Voir les directs
|
cta_live: Voir les directs
|
||||||
|
stores_label: Pour passer en direct depuis le smartphone
|
||||||
|
store_app_store_kicker: Télécharger dans
|
||||||
|
store_app_store_name: App Store
|
||||||
|
store_app_store_aria: Télécharger Match Live TV dans l'App Store
|
||||||
|
store_play_kicker: Disponible sur
|
||||||
|
store_play_name: Google Play
|
||||||
|
store_play_aria: Télécharger Match Live TV sur Google Play
|
||||||
features_aria: Avantages principaux
|
features_aria: Avantages principaux
|
||||||
feature_stable: Directs stables
|
feature_stable: Directs stables
|
||||||
feature_archive: Archive sécurisée
|
feature_archive: Archive sécurisée
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ it:
|
|||||||
tagline: Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — guardano dal browser, senza installare nulla. E se te la sei persa, la ritrovi nell'archivio.
|
tagline: Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — guardano dal browser, senza installare nulla. E se te la sei persa, la ritrovi nell'archivio.
|
||||||
cta_signup: Registra la tua squadra
|
cta_signup: Registra la tua squadra
|
||||||
cta_live: Guarda le dirette
|
cta_live: Guarda le dirette
|
||||||
|
stores_label: Per andare in diretta dallo smartphone
|
||||||
|
store_app_store_kicker: Scarica su
|
||||||
|
store_app_store_name: App Store
|
||||||
|
store_app_store_aria: Scarica Match Live TV su App Store
|
||||||
|
store_play_kicker: Disponibile su
|
||||||
|
store_play_name: Google Play
|
||||||
|
store_play_aria: Scarica Match Live TV su Google Play
|
||||||
features_aria: Vantaggi principali
|
features_aria: Vantaggi principali
|
||||||
feature_stable: Dirette stabili
|
feature_stable: Dirette stabili
|
||||||
feature_archive: Archivio sicuro
|
feature_archive: Archivio sicuro
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ Rails.application.routes.draw do
|
|||||||
patch :stop
|
patch :stop
|
||||||
patch :pause
|
patch :pause
|
||||||
patch :resume
|
patch :resume
|
||||||
|
patch :audio_mute
|
||||||
patch :score
|
patch :score
|
||||||
post :score_action
|
post :score_action
|
||||||
get :events
|
get :events
|
||||||
@@ -111,6 +112,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
|
||||||
|
|
||||||
@@ -143,6 +153,7 @@ Rails.application.routes.draw do
|
|||||||
patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score
|
patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score
|
||||||
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
||||||
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
post "regia/:token/resume", to: "public/regia#resume", as: :public_regia_resume
|
||||||
|
post "regia/:token/audio_mute", to: "public/regia#audio_mute", as: :public_regia_audio_mute
|
||||||
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||||
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
|
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
:concurrency: 5
|
:concurrency: 5
|
||||||
:queues:
|
:queues:
|
||||||
- default
|
|
||||||
- critical
|
- critical
|
||||||
|
- youtube_relay_home
|
||||||
|
- 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
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user