Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fecccaafd | ||
|
|
bc2257efd2 | ||
|
|
4af4fa68ac | ||
|
|
45bdda7c95 | ||
|
|
b926531447 | ||
|
|
da6fc1d523 | ||
|
|
1d1cbf9f3f | ||
|
|
dd9901519a | ||
|
|
53449c5d3c | ||
|
|
db908e3109 | ||
|
|
5857f60d79 | ||
|
|
83a804a709 | ||
|
|
b8694a6b7b | ||
|
|
5daada81b0 | ||
|
|
d85bab30d1 | ||
|
|
7b3256c8a0 | ||
|
|
9bc89fceba | ||
|
|
d5ec266437 | ||
|
|
a448ad59ee | ||
|
|
74a156cedc | ||
|
|
97d787f758 |
@@ -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
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,23 @@ module Admin
|
|||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password].blank? || params[:password].length < 8
|
if params[:password] != params[:password_confirmation]
|
||||||
flash.now[:alert] = t("admin.flash.password_too_short")
|
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password] != params[:password_confirmation]
|
if (code = PasswordComplexity.violation(params[:password]))
|
||||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
key = case code
|
||||||
|
when :blank, :too_short then :password_too_short
|
||||||
|
when :too_long then :password_too_long
|
||||||
|
else :password_too_weak
|
||||||
|
end
|
||||||
|
flash.now[:alert] = t("admin.flash.#{key}")
|
||||||
|
return render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if PasswordComplexity.same_as_current?(current_admin_account, params[:password])
|
||||||
|
flash.now[:alert] = t("admin.flash.password_same_as_current")
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class AccountsController < ApplicationController
|
||||||
|
def show
|
||||||
|
render json: user_json(current_user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
if name.blank?
|
||||||
|
return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.update(name: name)
|
||||||
|
render json: user_json(current_user)
|
||||||
|
else
|
||||||
|
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def password
|
||||||
|
result = Users::ChangePassword.call(
|
||||||
|
user: current_user,
|
||||||
|
current_password: params[:current_password],
|
||||||
|
password: params[:password],
|
||||||
|
password_confirmation: params[:password_confirmation]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.ok?
|
||||||
|
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: { message: I18n.t("flash.accounts.password_updated") }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def user_json(user)
|
||||||
|
{
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def password_error_message(code)
|
||||||
|
case code
|
||||||
|
when :current_incorrect then I18n.t("flash.accounts.password_current_incorrect")
|
||||||
|
when :too_short then I18n.t("flash.accounts.password_too_short")
|
||||||
|
when :too_long then I18n.t("flash.accounts.password_too_long")
|
||||||
|
when :too_weak then I18n.t("flash.accounts.password_too_weak")
|
||||||
|
when :same_as_current then I18n.t("flash.accounts.password_same_as_current")
|
||||||
|
when :mismatch then I18n.t("flash.accounts.password_mismatch")
|
||||||
|
else I18n.t("flash.accounts.password_update_failed")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
module Api
|
module Api
|
||||||
module V1
|
module V1
|
||||||
class AuthController < ApplicationController
|
class AuthController < ApplicationController
|
||||||
skip_before_action :authenticate_request!, only: %i[login refresh register]
|
skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
|
||||||
|
|
||||||
def register
|
def register
|
||||||
user = User.new(
|
user = User.new(
|
||||||
@@ -22,18 +22,18 @@ module Api
|
|||||||
if user&.authenticate(params[:password])
|
if user&.authenticate(params[:password])
|
||||||
render json: token_response(user), status: :ok
|
render json: token_response(user), status: :ok
|
||||||
else
|
else
|
||||||
render json: { error: "Invalid credentials" }, status: :unauthorized
|
render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def logout
|
def logout
|
||||||
render json: { message: "Logged out" }
|
render json: { message: I18n.t("flash.sessions.logged_out") }
|
||||||
end
|
end
|
||||||
|
|
||||||
def refresh
|
def refresh
|
||||||
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
||||||
user = User.find_by(id: payload&.dig(:user_id))
|
user = User.find_by(id: payload&.dig(:user_id))
|
||||||
return render json: { error: "Invalid token" }, status: :unauthorized unless user
|
return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user
|
||||||
|
|
||||||
render json: token_response(user)
|
render json: token_response(user)
|
||||||
end
|
end
|
||||||
@@ -42,6 +42,13 @@ module Api
|
|||||||
render json: user_json(current_user)
|
render json: user_json(current_user)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def forgot_password
|
||||||
|
Users::RequestPasswordReset.call(email: params[:email])
|
||||||
|
render json: {
|
||||||
|
message: I18n.t("flash.password_resets.email_sent")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def token_response(user)
|
def token_response(user)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -189,7 +195,8 @@ module Api
|
|||||||
quality_preset: session.quality_preset,
|
quality_preset: session.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
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
class ApplicationController < ActionController::API
|
class ApplicationController < ActionController::API
|
||||||
include ActionController::HttpAuthentication::Token::ControllerMethods
|
include ActionController::HttpAuthentication::Token::ControllerMethods
|
||||||
|
|
||||||
|
before_action :set_api_locale
|
||||||
before_action :authenticate_request!
|
before_action :authenticate_request!
|
||||||
|
|
||||||
attr_reader :current_user
|
attr_reader :current_user
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def set_api_locale
|
||||||
|
explicit = LocaleResolver.normalize(request.headers["X-Locale"])
|
||||||
|
I18n.locale = explicit ||
|
||||||
|
LocaleResolver.from_accept_language(request.headers["Accept-Language"]) ||
|
||||||
|
I18n.default_locale
|
||||||
|
end
|
||||||
|
|
||||||
def authenticate_request!
|
def authenticate_request!
|
||||||
token = bearer_token
|
token = bearer_token
|
||||||
payload = JsonWebToken.decode(token)
|
payload = JsonWebToken.decode(token)
|
||||||
@current_user = User.find_by(id: payload[:user_id]) if payload
|
@current_user = User.find_by(id: payload[:user_id]) if payload
|
||||||
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
|
render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user
|
||||||
end
|
end
|
||||||
|
|
||||||
def bearer_token
|
def bearer_token
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
module Public
|
||||||
|
class AccountsController < WebBaseController
|
||||||
|
before_action :require_login!
|
||||||
|
|
||||||
|
def show
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
name = params[:name].to_s.strip
|
||||||
|
if name.blank?
|
||||||
|
flash.now[:alert] = t("flash.accounts.name_required")
|
||||||
|
return render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if current_user.update(name: name)
|
||||||
|
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = current_user.errors.full_messages.to_sentence
|
||||||
|
render :show, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_password
|
||||||
|
result = Users::ChangePassword.call(
|
||||||
|
user: current_user,
|
||||||
|
current_password: params[:current_password],
|
||||||
|
password: params[:password],
|
||||||
|
password_confirmation: params[:password_confirmation]
|
||||||
|
)
|
||||||
|
|
||||||
|
unless result.ok?
|
||||||
|
redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -11,17 +11,8 @@ module Public
|
|||||||
load_club_billing_context_for_pricing
|
load_club_billing_context_for_pricing
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def support
|
||||||
|
@app_store_review_chrome = true
|
||||||
def load_club_billing_context_for_pricing
|
|
||||||
return unless logged_in?
|
|
||||||
|
|
||||||
@club = current_user.primary_club
|
|
||||||
return unless @club
|
|
||||||
|
|
||||||
@subscription = @club.subscription
|
|
||||||
@team = @club.teams.order(:name).first
|
|
||||||
@entitlements = @team&.entitlements
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy
|
def privacy
|
||||||
@@ -38,5 +29,18 @@ module Public
|
|||||||
|
|
||||||
def pallavolo
|
def pallavolo
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def load_club_billing_context_for_pricing
|
||||||
|
return unless logged_in?
|
||||||
|
|
||||||
|
@club = current_user.primary_club
|
||||||
|
return unless @club
|
||||||
|
|
||||||
|
@subscription = @club.subscription
|
||||||
|
@team = @club.teams.order(:name).first
|
||||||
|
@entitlements = @team&.entitlements
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
user = User.find_by(email: params[:email]&.downcase&.strip)
|
Users::RequestPasswordReset.call(email: params[:email])
|
||||||
if user
|
|
||||||
token = user.generate_password_reset!
|
|
||||||
UserMailer.password_reset(user, token).deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
redirect_to public_login_path,
|
redirect_to public_login_path,
|
||||||
notice: t("flash.password_resets.email_sent")
|
notice: t("flash.password_resets.email_sent")
|
||||||
@@ -17,7 +13,7 @@ module Public
|
|||||||
def edit
|
def edit
|
||||||
@user = User.find_by_password_reset_token(params[:token])
|
@user = User.find_by_password_reset_token(params[:token])
|
||||||
if @user.nil? || @user.password_reset_expired?
|
if @user.nil? || @user.password_reset_expired?
|
||||||
redirect_to new_public_password_reset_path,
|
redirect_to public_password_forgot_path,
|
||||||
alert: t("flash.password_resets.invalid_or_expired_link")
|
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@@ -27,19 +23,25 @@ module Public
|
|||||||
def update
|
def update
|
||||||
@user = User.find_by_password_reset_token(params[:token])
|
@user = User.find_by_password_reset_token(params[:token])
|
||||||
if @user.nil? || @user.password_reset_expired?
|
if @user.nil? || @user.password_reset_expired?
|
||||||
redirect_to new_public_password_reset_path,
|
redirect_to public_password_forgot_path,
|
||||||
alert: t("flash.password_resets.invalid_or_expired_link")
|
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password].blank? || params[:password].length < 8
|
if params[:password] != params[:password_confirmation]
|
||||||
flash.now[:alert] = t("flash.password_resets.password_min_length")
|
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||||
@token = params[:token]
|
@token = params[:token]
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:password] != params[:password_confirmation]
|
if (code = PasswordComplexity.violation(params[:password]))
|
||||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}")
|
||||||
|
@token = params[:token]
|
||||||
|
return render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
if PasswordComplexity.same_as_current?(@user, params[:password])
|
||||||
|
flash.now[:alert] = t("flash.password_resets.password_same_as_current")
|
||||||
@token = params[:token]
|
@token = params[:token]
|
||||||
return render :edit, status: :unprocessable_entity
|
return render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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]
|
skip_before_action :verify_authenticity_token, only: %i[score pause resume stop audio_mute]
|
||||||
|
|
||||||
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
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_session_from_token
|
def set_session_from_token
|
||||||
@@ -78,6 +84,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])),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ module Public
|
|||||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||||
|
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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"),
|
||||||
@@ -58,6 +61,8 @@ module Public
|
|||||||
streamEnded: t("regia.preview_ended"),
|
streamEnded: t("regia.preview_ended"),
|
||||||
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"),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class AdminAccount < ApplicationRecord
|
class AdminAccount < ApplicationRecord
|
||||||
|
include PasswordComplexity
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
|
|
||||||
validates :username, presence: true, uniqueness: true
|
validates :username, presence: true, uniqueness: true
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Criteri "di mercato" (stile Cognito/Auth0 bilanciato):
|
||||||
|
# - minimo 8 caratteri (max 72 per bcrypt)
|
||||||
|
# - almeno 3 classi su 4: minuscole, maiuscole, numeri, simboli
|
||||||
|
module PasswordComplexity
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
MIN_LENGTH = 8
|
||||||
|
MAX_LENGTH = 72
|
||||||
|
REQUIRED_CLASSES = 3
|
||||||
|
|
||||||
|
CLASS_CHECKS = {
|
||||||
|
lowercase: /[a-z]/,
|
||||||
|
uppercase: /[A-Z]/,
|
||||||
|
digit: /\d/,
|
||||||
|
symbol: /[^A-Za-z0-9]/
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def violation(password)
|
||||||
|
value = password.to_s
|
||||||
|
return :blank if value.blank?
|
||||||
|
return :too_short if value.length < MIN_LENGTH
|
||||||
|
return :too_long if value.bytesize > MAX_LENGTH
|
||||||
|
return :too_weak unless strong_enough?(value)
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def strong_enough?(password)
|
||||||
|
matched = CLASS_CHECKS.count { |_, pattern| password.match?(pattern) }
|
||||||
|
matched >= REQUIRED_CLASSES
|
||||||
|
end
|
||||||
|
|
||||||
|
# Confronta con il digest già salvato (prima di assegnare la nuova password).
|
||||||
|
def same_as_current?(record, password)
|
||||||
|
return false if password.blank? || !record.respond_to?(:authenticate)
|
||||||
|
|
||||||
|
record.authenticate(password).present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def requirement_summary
|
||||||
|
I18n.t("password_policy.hint")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
included do
|
||||||
|
validate :password_meets_complexity_policy, if: -> { password.present? }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def password_meets_complexity_policy
|
||||||
|
case PasswordComplexity.violation(password)
|
||||||
|
when :too_short
|
||||||
|
errors.add(:password, :too_short, count: MIN_LENGTH)
|
||||||
|
when :too_long
|
||||||
|
errors.add(:password, :too_long, count: MAX_LENGTH)
|
||||||
|
when :too_weak
|
||||||
|
errors.add(:password, :complexity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
|
include PasswordComplexity
|
||||||
|
|
||||||
ROLES = %w[admin coach parent volunteer].freeze
|
ROLES = %w[admin coach parent volunteer].freeze
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ module Ops
|
|||||||
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
||||||
)
|
)
|
||||||
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
||||||
ok_finding("garage_storage:ok", "Garage storage OK")
|
ok_finding("garage_storage:head", "Garage storage OK")
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -116,31 +116,30 @@ module Streams
|
|||||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails.
|
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
|
||||||
|
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
|
||||||
def youtube_ffmpeg_args(intake_source, output)
|
def youtube_ffmpeg_args(intake_source, output)
|
||||||
mode, url = intake_source
|
mode, url = intake_source
|
||||||
|
common_head = [
|
||||||
|
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||||
|
"-fflags", "+genpts+discardcorrupt"
|
||||||
|
]
|
||||||
|
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
|
||||||
|
|
||||||
if mode == :rtmp
|
if mode == :rtmp
|
||||||
return [
|
return common_head + [
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
|
||||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-i", url,
|
"-i", url
|
||||||
"-c:v", "copy",
|
] + copy_tail
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
|
||||||
"-bsf:a", "aac_adtstoasc",
|
|
||||||
"-f", "flv", output
|
|
||||||
]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
[
|
common_head + [
|
||||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
|
||||||
"-fflags", "+genpts+discardcorrupt",
|
|
||||||
"-rw_timeout", "15000000",
|
"-rw_timeout", "15000000",
|
||||||
"-live_start_index", "-1",
|
"-live_start_index", "-1",
|
||||||
"-i", url,
|
"-i", url,
|
||||||
"-c:v", "copy",
|
"-c:v", "copy",
|
||||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
"-c:a", "copy",
|
||||||
"-bsf:a", "aac_adtstoasc",
|
"-bsf:a", "aac_adtstoasc",
|
||||||
"-f", "flv", output
|
"-f", "flv", output
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
module Users
|
||||||
|
class ChangePassword
|
||||||
|
Result = Struct.new(:ok?, :error, keyword_init: true)
|
||||||
|
|
||||||
|
def self.call(user:, current_password:, password:, password_confirmation:)
|
||||||
|
new(
|
||||||
|
user: user,
|
||||||
|
current_password: current_password,
|
||||||
|
password: password,
|
||||||
|
password_confirmation: password_confirmation
|
||||||
|
).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(user:, current_password:, password:, password_confirmation:)
|
||||||
|
@user = user
|
||||||
|
@current_password = current_password.to_s
|
||||||
|
@password = password.to_s
|
||||||
|
@password_confirmation = password_confirmation.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
unless @user.authenticate(@current_password)
|
||||||
|
return Result.new(ok?: false, error: :current_incorrect)
|
||||||
|
end
|
||||||
|
|
||||||
|
if @password != @password_confirmation
|
||||||
|
return Result.new(ok?: false, error: :mismatch)
|
||||||
|
end
|
||||||
|
|
||||||
|
if (code = PasswordComplexity.violation(@password))
|
||||||
|
return Result.new(ok?: false, error: code == :blank ? :too_short : code)
|
||||||
|
end
|
||||||
|
|
||||||
|
if PasswordComplexity.same_as_current?(@user, @password)
|
||||||
|
return Result.new(ok?: false, error: :same_as_current)
|
||||||
|
end
|
||||||
|
|
||||||
|
unless @user.update(password: @password)
|
||||||
|
complexity_error = @user.errors.details[:password]&.any? { |d| d[:error] == :complexity }
|
||||||
|
return Result.new(ok?: false, error: complexity_error ? :too_weak : :too_short)
|
||||||
|
end
|
||||||
|
|
||||||
|
@user.clear_password_reset!
|
||||||
|
Result.new(ok?: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module Users
|
||||||
|
class RequestPasswordReset
|
||||||
|
def self.call(email:)
|
||||||
|
new(email: email).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(email:)
|
||||||
|
@email = email.to_s.downcase.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
user = User.find_by(email: @email)
|
||||||
|
return if user.nil?
|
||||||
|
|
||||||
|
token = user.generate_password_reset!
|
||||||
|
UserMailer.password_reset(user, token).deliver_now
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,17 +8,17 @@
|
|||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
<link rel="stylesheet" href="/marketing.css?v=50">
|
||||||
</head>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||||
<%= render "shared/cookie_banner" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
<%= render "shared/marketing_nav" %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||||
<main>
|
<main>
|
||||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
<%= render "shared/marketing_footer" %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
||||||
<script src="/branding-form.js?v=1" defer></script>
|
<script src="/branding-form.js?v=1" defer></script>
|
||||||
<script src="/roster-form.js?v=1" defer></script>
|
<script src="/roster-form.js?v=1" defer></script>
|
||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
<link rel="stylesheet" href="/marketing.css?v=50">
|
||||||
<link rel="stylesheet" href="/live.css?v=26">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<% elsif current_user.manageable_teams.any? %>
|
<% elsif current_user.manageable_teams.any? %>
|
||||||
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
· <%= link_to t("nav.account"), public_account_path %>
|
||||||
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
· <%= link_to t("nav.login"), public_login_path %>
|
· <%= link_to t("nav.login"), public_login_path %>
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<% content_for :title, t("auth.account.meta_title") %>
|
||||||
|
<% content_for :meta_description, t("auth.account.meta_description") %>
|
||||||
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
|
<section class="auth-page">
|
||||||
|
<h1><%= t("auth.account.title") %></h1>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.profile_heading") %></h2>
|
||||||
|
<%= form_with url: public_account_path, method: :patch, local: true do %>
|
||||||
|
<p>
|
||||||
|
<label for="account_email"><%= t("auth.email") %></label><br>
|
||||||
|
<input type="email" id="account_email" value="<%= current_user.email %>" readonly
|
||||||
|
class="input" autocomplete="username"
|
||||||
|
style="width:100%;opacity:0.75;cursor:not-allowed">
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="margin-top:-0.5rem;font-size:0.9rem">
|
||||||
|
<%= t("auth.account.role_label", role: current_user.role) %>
|
||||||
|
</p>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :name,
|
||||||
|
label: t("auth.account.name_label"),
|
||||||
|
value: current_user.name,
|
||||||
|
required: true,
|
||||||
|
autocomplete: "name",
|
||||||
|
masked: false %>
|
||||||
|
<%= submit_tag t("auth.account.save_profile"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
|
||||||
|
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||||
|
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :current_password,
|
||||||
|
label: t("auth.account.current_password_label"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "current-password" %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :password,
|
||||||
|
label: t("auth.account.new_password_label"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "new-password" %>
|
||||||
|
<%= render "shared/input_toggle",
|
||||||
|
name: :password_confirmation,
|
||||||
|
label: t("auth.password_confirmation"),
|
||||||
|
input_type: "password",
|
||||||
|
required: true,
|
||||||
|
autocomplete: "new-password" %>
|
||||||
|
<%= submit_tag t("auth.account.save_password"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<% if @subscription.admin_comped_reason.present? %>
|
<% if @subscription.admin_comped_reason.present? %>
|
||||||
(<%= @subscription.admin_comped_reason %>)
|
(<%= @subscription.admin_comped_reason %>)
|
||||||
<% end %>.
|
<% end %>.
|
||||||
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlive.it", "info@matchlive.it")) %>
|
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlivetv.it", "info@matchlivetv.it")) %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -2,25 +2,27 @@
|
|||||||
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="card" style="max-width:480px">
|
<section class="auth-page">
|
||||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
<div class="card">
|
||||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
||||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||||
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
|
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||||
</p>
|
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
|
||||||
<% if logged_in? %>
|
</p>
|
||||||
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
<% if logged_in? %>
|
||||||
<% else %>
|
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||||
<p><%= raw t(
|
<% else %>
|
||||||
"auth.invitation.login_or_signup_html",
|
<p><%= raw t(
|
||||||
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
|
"auth.invitation.login_or_signup_html",
|
||||||
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
|
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
|
||||||
email: @invitation.email
|
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
|
||||||
) %></p>
|
email: @invitation.email
|
||||||
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
) %></p>
|
||||||
<% end %>
|
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||||
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
<% end %>
|
||||||
<%= t("auth.invitation.mobile_app_label") %>
|
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
||||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
<%= t("auth.invitation.mobile_app_label") %>
|
||||||
</p>
|
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -46,7 +46,8 @@
|
|||||||
|
|
||||||
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
||||||
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
||||||
<table class="legal-table">
|
<div class="table-scroll">
|
||||||
|
<table class="legal-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
<tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -64,7 +65,8 @@
|
|||||||
<td><%= t("legal.cookies.s4_1_row2_provider") %></td>
|
<td><%= t("legal.cookies.s4_1_row2_provider") %></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -75,7 +77,8 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<table class="legal-table">
|
<div class="table-scroll">
|
||||||
|
<table class="legal-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
<tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -99,7 +102,8 @@
|
|||||||
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<%= raw t(
|
<%= raw t(
|
||||||
"legal.cookies.s4_2_p2_html",
|
"legal.cookies.s4_2_p2_html",
|
||||||
|
|||||||
@@ -22,24 +22,26 @@
|
|||||||
|
|
||||||
<%= render "shared/plan_cards" %>
|
<%= render "shared/plan_cards" %>
|
||||||
|
|
||||||
<table class="compare-table">
|
<div class="table-scroll">
|
||||||
<thead>
|
<table class="compare-table">
|
||||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
<thead>
|
||||||
</thead>
|
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||||
<tbody>
|
</thead>
|
||||||
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></tr>
|
<tbody>
|
||||||
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</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_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_matches") %></td><td>1</td><td>3</td><td>10</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_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_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_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_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_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_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_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||||
</tbody>
|
<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>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:32px;text-align:center">
|
<div class="card" style="margin-top:32px;text-align:center">
|
||||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||||
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
||||||
<%= mail_to "info@matchlive.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -77,7 +77,8 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2><%= t("legal.privacy.s5_title") %></h2>
|
<h2><%= t("legal.privacy.s5_title") %></h2>
|
||||||
<table class="legal-table">
|
<div class="table-scroll">
|
||||||
|
<table class="legal-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -107,7 +108,8 @@
|
|||||||
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<% content_for :title, t("legal.support.title") %>
|
||||||
|
<% content_for :meta_description, t("legal.support.meta_description") %>
|
||||||
|
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
|
||||||
|
|
||||||
|
<div class="wrap legal-doc">
|
||||||
|
<h1><%= t("legal.support.h1") %></h1>
|
||||||
|
<p><%= t("legal.support.intro") %></p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= t("legal.support.email_label") %>
|
||||||
|
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.access_title") %></h2>
|
||||||
|
<p><%= t("legal.support.access_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.live_title") %></h2>
|
||||||
|
<p><%= t("legal.support.live_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.team_title") %></h2>
|
||||||
|
<p><%= t("legal.support.team_body") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= t("legal.support.privacy_title") %></h2>
|
||||||
|
<p>
|
||||||
|
<%= raw t(
|
||||||
|
"legal.support.privacy_body_html",
|
||||||
|
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<section class="auth-page">
|
<section class="auth-page">
|
||||||
<h1><%= t("auth.password_reset.title") %></h1>
|
<h1><%= t("auth.password_reset.title") %></h1>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||||
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
||||||
<%= hidden_field_tag :token, @token %>
|
<%= hidden_field_tag :token, @token %>
|
||||||
<%= render "shared/input_toggle",
|
<%= render "shared/input_toggle",
|
||||||
|
|||||||
@@ -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-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||||
@@ -26,7 +27,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">
|
||||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
|
|
||||||
<% unless @stream_closed %>
|
<% unless @stream_closed %>
|
||||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause"><%= @session.paused? ? t("regia.resume") : t("regia.pause") %></button>
|
||||||
|
<button type="button" class="regia-btn regia-btn--outline<%= @session.audio_muted ? ' is-muted' : '' %>" id="btn-audio-mute"><%= @session.audio_muted ? t("regia.unmute") : t("regia.mute") %></button>
|
||||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,4 +97,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/regia.js?v=8"></script>
|
<script src="/regia.js?v=9"></script>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
required: true,
|
required: true,
|
||||||
minlength: 8,
|
minlength: 8,
|
||||||
autocomplete: "new-password" %>
|
autocomplete: "new-password" %>
|
||||||
|
<p class="muted" style="font-size:0.9rem;margin-top:-0.5rem"><%= t("password_policy.hint") %></p>
|
||||||
<%= render "shared/input_toggle",
|
<%= render "shared/input_toggle",
|
||||||
name: "user[password_confirmation]",
|
name: "user[password_confirmation]",
|
||||||
id: "user_password_confirmation",
|
id: "user_password_confirmation",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:24px;text-align:center">
|
<div class="card" style="margin-top:24px;text-align:center">
|
||||||
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlive.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlivetv.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
||||||
<% if payments.any? %>
|
<% if payments.any? %>
|
||||||
|
<div class="table-scroll">
|
||||||
<table class="data billing-table">
|
<table class="data billing-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -58,10 +58,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggle.addEventListener("click", function (e) {
|
toggle.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setOpen(menu.hidden);
|
setOpen(menu.hidden);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keep parent mobile nav open while interacting with the switcher UI.
|
||||||
|
root.addEventListener("click", function (e) {
|
||||||
|
if (e.target.closest(".lang-switcher__toggle")) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("click", function (e) {
|
document.addEventListener("click", function (e) {
|
||||||
if (!root.contains(e.target)) setOpen(false);
|
if (!root.contains(e.target)) setOpen(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div>
|
||||||
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
|
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||||
|
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||||
|
<%= link_to t("common.terms"), public_termini_path %>
|
||||||
|
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||||
|
</div>
|
||||||
|
<div class="site-footer__legal">
|
||||||
|
<p><%= t("footer.copyright") %></p>
|
||||||
|
<p><%= t("footer.responsibility") %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
@@ -19,6 +19,15 @@
|
|||||||
|
|
||||||
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||||
<div class="nav-panel">
|
<div class="nav-panel">
|
||||||
|
<div class="nav-mobile-head">
|
||||||
|
<%= link_to root_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||||
|
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||||
|
<span class="brand">Match <span>Live TV</span></span>
|
||||||
|
<% end %>
|
||||||
|
<div class="nav-lang">
|
||||||
|
<%= render "shared/language_switcher" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
<%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
||||||
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
||||||
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||||
@@ -34,14 +43,12 @@
|
|||||||
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= link_to t("nav.account"), public_account_path, class: (request.path == "/account" ? "nav-link-item nav-active" : "nav-link-item") %>
|
||||||
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||||
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="nav-lang">
|
|
||||||
<%= render "shared/language_switcher" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -77,8 +84,16 @@
|
|||||||
|
|
||||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||||
|
|
||||||
|
function shouldCloseNavOnControl(el) {
|
||||||
|
// Language toggle must keep the mobile menu open; only a locale choice may close it.
|
||||||
|
if (!el.closest("[data-lang-switcher]")) return true;
|
||||||
|
return el.classList.contains("lang-switcher__option");
|
||||||
|
}
|
||||||
|
|
||||||
nav.querySelectorAll("a, button").forEach(function (el) {
|
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||||
el.addEventListener("click", closeMenu);
|
el.addEventListener("click", function () {
|
||||||
|
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", function () {
|
window.addEventListener("resize", function () {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<%# Chrome minimale per App Store Review: branding, lingua, Privacy e Supporto — senza CTA commerciali. %>
|
||||||
|
<div class="site-chrome">
|
||||||
|
<div class="site-masthead">
|
||||||
|
<div class="wrap mast-inner">
|
||||||
|
<%= link_to public_support_path, class: "mast-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||||
|
<img class="mast-brand-logo" src="/logo.png?v=3" alt="Match Live TV" width="40" height="40" decoding="async">
|
||||||
|
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="mast-tools">
|
||||||
|
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||||
|
<div class="nav-panel">
|
||||||
|
<div class="nav-mobile-head">
|
||||||
|
<%= link_to public_support_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||||
|
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||||
|
<span class="brand">Match <span>Live TV</span></span>
|
||||||
|
<% end %>
|
||||||
|
<div class="nav-lang">
|
||||||
|
<%= render "shared/language_switcher" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%= link_to t("common.support"), public_support_path, class: "nav-active" %>
|
||||||
|
<%= link_to t("common.privacy"), public_privacy_path, class: (request.path == "/privacy" ? "nav-active" : nil) %>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var chrome = document.querySelector(".site-chrome");
|
||||||
|
var toggle = document.querySelector(".nav-toggle");
|
||||||
|
var backdrop = document.getElementById("nav-backdrop");
|
||||||
|
var nav = document.getElementById("site-nav");
|
||||||
|
if (!chrome || !toggle || !nav) return;
|
||||||
|
var openLabel = <%= raw t("nav.open_menu").to_json %>;
|
||||||
|
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
|
||||||
|
|
||||||
|
function setOpen(open) {
|
||||||
|
chrome.classList.toggle("nav-open", open);
|
||||||
|
document.body.classList.toggle("nav-menu-open", open);
|
||||||
|
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
|
||||||
|
nav.setAttribute("aria-hidden", open ? "false" : "true");
|
||||||
|
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMenu() { setOpen(false); }
|
||||||
|
|
||||||
|
toggle.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||||
|
|
||||||
|
function shouldCloseNavOnControl(el) {
|
||||||
|
if (!el.closest("[data-lang-switcher]")) return true;
|
||||||
|
return el.classList.contains("lang-switcher__option");
|
||||||
|
}
|
||||||
|
|
||||||
|
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () {
|
||||||
|
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape") closeMenu();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -78,7 +78,13 @@ module MatchLiveTv
|
|||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_email
|
def privacy_controller_email
|
||||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
|
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||||
|
end
|
||||||
|
|
||||||
|
# Email di supporto (App Store Support URL e contatti assistenza).
|
||||||
|
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
|
||||||
|
def support_email
|
||||||
|
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_address
|
def privacy_controller_address
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ de:
|
|||||||
logout_success: Abgemeldet
|
logout_success: Abgemeldet
|
||||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||||
password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein
|
password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein
|
||||||
|
password_too_long: Das neue Passwort darf höchstens 72 Zeichen lang sein
|
||||||
|
password_too_weak: Das neue Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||||
|
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||||
password_mismatch: Die Passwörter stimmen nicht überein
|
password_mismatch: Die Passwörter stimmen nicht überein
|
||||||
password_updated: Passwort aktualisiert
|
password_updated: Passwort aktualisiert
|
||||||
ops_acknowledged: Vorfall übernommen
|
ops_acknowledged: Vorfall übernommen
|
||||||
@@ -53,7 +56,7 @@ de:
|
|||||||
edit:
|
edit:
|
||||||
title: Passwort ändern
|
title: Passwort ändern
|
||||||
current_password_label: Aktuelles Passwort
|
current_password_label: Aktuelles Passwort
|
||||||
new_password_label: "Neues Passwort (mind. 8 Zeichen)"
|
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||||
confirm_password_label: Neues Passwort bestätigen
|
confirm_password_label: Neues Passwort bestätigen
|
||||||
submit: Passwort speichern
|
submit: Passwort speichern
|
||||||
cancel: Abbrechen
|
cancel: Abbrechen
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ en:
|
|||||||
logout_success: Signed out
|
logout_success: Signed out
|
||||||
password_current_incorrect: Current password is incorrect
|
password_current_incorrect: Current password is incorrect
|
||||||
password_too_short: The new password must be at least 8 characters long
|
password_too_short: The new password must be at least 8 characters long
|
||||||
|
password_too_long: New password cannot exceed 72 characters
|
||||||
|
password_too_weak: "New password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||||
|
password_same_as_current: New password must be different from the current password
|
||||||
password_mismatch: Passwords do not match
|
password_mismatch: Passwords do not match
|
||||||
password_updated: Password updated
|
password_updated: Password updated
|
||||||
ops_acknowledged: Incident acknowledged
|
ops_acknowledged: Incident acknowledged
|
||||||
@@ -53,7 +56,7 @@ en:
|
|||||||
edit:
|
edit:
|
||||||
title: Change password
|
title: Change password
|
||||||
current_password_label: Current password
|
current_password_label: Current password
|
||||||
new_password_label: "New password (min. 8 characters)"
|
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||||
confirm_password_label: Confirm new password
|
confirm_password_label: Confirm new password
|
||||||
submit: Save password
|
submit: Save password
|
||||||
cancel: Cancel
|
cancel: Cancel
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ es:
|
|||||||
logout_success: Sesión cerrada
|
logout_success: Sesión cerrada
|
||||||
password_current_incorrect: La contraseña actual no es correcta
|
password_current_incorrect: La contraseña actual no es correcta
|
||||||
password_too_short: La nueva contraseña debe tener al menos 8 caracteres
|
password_too_short: La nueva contraseña debe tener al menos 8 caracteres
|
||||||
|
password_too_long: La nueva contraseña no puede superar los 72 caracteres
|
||||||
|
password_too_weak: "La nueva contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||||
|
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||||
password_mismatch: Las contraseñas no coinciden
|
password_mismatch: Las contraseñas no coinciden
|
||||||
password_updated: Contraseña actualizada
|
password_updated: Contraseña actualizada
|
||||||
ops_acknowledged: Incidencia asumida
|
ops_acknowledged: Incidencia asumida
|
||||||
@@ -53,7 +56,7 @@ es:
|
|||||||
edit:
|
edit:
|
||||||
title: Cambiar contraseña
|
title: Cambiar contraseña
|
||||||
current_password_label: Contraseña actual
|
current_password_label: Contraseña actual
|
||||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||||
confirm_password_label: Confirma la nueva contraseña
|
confirm_password_label: Confirma la nueva contraseña
|
||||||
submit: Guardar contraseña
|
submit: Guardar contraseña
|
||||||
cancel: Cancelar
|
cancel: Cancelar
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ fr:
|
|||||||
logout_success: Déconnecté
|
logout_success: Déconnecté
|
||||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||||
password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères
|
password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères
|
||||||
|
password_too_long: Le nouveau mot de passe ne peut pas dépasser 72 caractères
|
||||||
|
password_too_weak: "Le nouveau mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||||
|
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||||
password_mismatch: Les mots de passe ne correspondent pas
|
password_mismatch: Les mots de passe ne correspondent pas
|
||||||
password_updated: Mot de passe mis à jour
|
password_updated: Mot de passe mis à jour
|
||||||
ops_acknowledged: Incident pris en charge
|
ops_acknowledged: Incident pris en charge
|
||||||
@@ -53,7 +56,7 @@ fr:
|
|||||||
edit:
|
edit:
|
||||||
title: Changer le mot de passe
|
title: Changer le mot de passe
|
||||||
current_password_label: Mot de passe actuel
|
current_password_label: Mot de passe actuel
|
||||||
new_password_label: "Nouveau mot de passe (min. 8 caractères)"
|
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||||
confirm_password_label: Confirmer le nouveau mot de passe
|
confirm_password_label: Confirmer le nouveau mot de passe
|
||||||
submit: Enregistrer le mot de passe
|
submit: Enregistrer le mot de passe
|
||||||
cancel: Annuler
|
cancel: Annuler
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ it:
|
|||||||
logout_success: Disconnesso
|
logout_success: Disconnesso
|
||||||
password_current_incorrect: Password attuale non corretta
|
password_current_incorrect: Password attuale non corretta
|
||||||
password_too_short: La nuova password deve avere almeno 8 caratteri
|
password_too_short: La nuova password deve avere almeno 8 caratteri
|
||||||
|
password_too_long: La nuova password non può superare i 72 caratteri
|
||||||
|
password_too_weak: "La nuova password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||||
|
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||||
password_mismatch: Le password non coincidono
|
password_mismatch: Le password non coincidono
|
||||||
password_updated: Password aggiornata
|
password_updated: Password aggiornata
|
||||||
ops_acknowledged: Incidente preso in carico
|
ops_acknowledged: Incidente preso in carico
|
||||||
@@ -53,7 +56,7 @@ it:
|
|||||||
edit:
|
edit:
|
||||||
title: Cambia password
|
title: Cambia password
|
||||||
current_password_label: Password attuale
|
current_password_label: Password attuale
|
||||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||||
confirm_password_label: Conferma nuova password
|
confirm_password_label: Conferma nuova password
|
||||||
submit: Salva password
|
submit: Salva password
|
||||||
cancel: Annulla
|
cancel: Annulla
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
de:
|
de:
|
||||||
|
password_policy:
|
||||||
|
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
|
||||||
|
activerecord:
|
||||||
|
attributes:
|
||||||
|
user:
|
||||||
|
password: Passwort
|
||||||
|
admin_account:
|
||||||
|
password: Passwort
|
||||||
|
errors:
|
||||||
|
models:
|
||||||
|
user:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||||
|
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||||
|
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||||
|
admin_account:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||||
|
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||||
|
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||||
club:
|
club:
|
||||||
back_to_club: "← Verein"
|
back_to_club: "← Verein"
|
||||||
sport_label: Hauptsportart
|
sport_label: Hauptsportart
|
||||||
@@ -419,6 +441,8 @@ de:
|
|||||||
preview_waiting: Vorschau wartet auf Signal…
|
preview_waiting: Vorschau wartet auf Signal…
|
||||||
pause: Pausieren
|
pause: Pausieren
|
||||||
resume: Livestream fortsetzen
|
resume: Livestream fortsetzen
|
||||||
|
mute: Audio stummschalten
|
||||||
|
unmute: Audio einschalten
|
||||||
stop: Livestream beenden
|
stop: Livestream beenden
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Satz gewonnen
|
set_won_title: Satz gewonnen
|
||||||
@@ -454,9 +478,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
|
||||||
@@ -600,12 +627,29 @@ de:
|
|||||||
welcome_back: Willkommen zurück!
|
welcome_back: Willkommen zurück!
|
||||||
invalid_credentials: E-Mail oder Passwort ungültig
|
invalid_credentials: E-Mail oder Passwort ungültig
|
||||||
logged_out: Abgemeldet
|
logged_out: Abgemeldet
|
||||||
|
unauthorized: Nicht autorisiert
|
||||||
|
invalid_token: Ungültiges Token
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts.
|
email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts.
|
||||||
invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an.
|
invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an.
|
||||||
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||||
|
password_too_short: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||||
|
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
|
||||||
|
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||||
|
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||||
password_mismatch: Die Passwörter stimmen nicht überein
|
password_mismatch: Die Passwörter stimmen nicht überein
|
||||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||||
|
accounts:
|
||||||
|
name_required: Der Name ist erforderlich
|
||||||
|
profile_updated: Profil aktualisiert.
|
||||||
|
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||||
|
password_too_short: Das Passwort muss mindestens 8 Zeichen haben
|
||||||
|
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
|
||||||
|
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||||
|
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||||
|
password_mismatch: Die Passwörter stimmen nicht überein
|
||||||
|
password_updated: Passwort aktualisiert.
|
||||||
|
password_update_failed: Passwort konnte nicht aktualisiert werden
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Download nicht verfügbar
|
download_unavailable: Download nicht verfügbar
|
||||||
not_available: Replay nicht verfügbar
|
not_available: Replay nicht verfügbar
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
en:
|
en:
|
||||||
|
password_policy:
|
||||||
|
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
|
||||||
|
activerecord:
|
||||||
|
errors:
|
||||||
|
models:
|
||||||
|
user:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "is too short (minimum is %{count} characters)"
|
||||||
|
too_long: "is too long (maximum is %{count} characters)"
|
||||||
|
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||||
|
admin_account:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "is too short (minimum is %{count} characters)"
|
||||||
|
too_long: "is too long (maximum is %{count} characters)"
|
||||||
|
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||||
club:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Main sport
|
sport_label: Main sport
|
||||||
@@ -419,6 +436,8 @@ en:
|
|||||||
preview_waiting: Waiting for signal…
|
preview_waiting: Waiting for signal…
|
||||||
pause: Pause stream
|
pause: Pause stream
|
||||||
resume: Resume stream
|
resume: Resume stream
|
||||||
|
mute: Mute audio
|
||||||
|
unmute: Unmute audio
|
||||||
stop: End stream
|
stop: End stream
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set won
|
set_won_title: Set won
|
||||||
@@ -454,9 +473,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
|
||||||
@@ -600,12 +622,29 @@ en:
|
|||||||
welcome_back: Welcome back!
|
welcome_back: Welcome back!
|
||||||
invalid_credentials: Invalid email or password
|
invalid_credentials: Invalid email or password
|
||||||
logged_out: Logged out
|
logged_out: Logged out
|
||||||
|
unauthorized: Unauthorized
|
||||||
|
invalid_token: Invalid token
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: If the email is registered, you'll receive a password reset link shortly.
|
email_sent: If the email is registered, you'll receive a password reset link shortly.
|
||||||
invalid_or_expired_link: Invalid or expired link. Request a new password reset.
|
invalid_or_expired_link: Invalid or expired link. Request a new password reset.
|
||||||
password_min_length: Password must be at least 8 characters
|
password_min_length: Password must be at least 8 characters
|
||||||
|
password_too_short: Password must be at least 8 characters
|
||||||
|
password_too_long: Password cannot exceed 72 characters
|
||||||
|
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||||
|
password_same_as_current: New password must be different from the current password
|
||||||
password_mismatch: Passwords don't match
|
password_mismatch: Passwords don't match
|
||||||
password_updated: Password updated. You can now log in.
|
password_updated: Password updated. You can now log in.
|
||||||
|
accounts:
|
||||||
|
name_required: Name is required
|
||||||
|
profile_updated: Profile updated.
|
||||||
|
password_current_incorrect: Current password is incorrect
|
||||||
|
password_too_short: Password must be at least 8 characters
|
||||||
|
password_too_long: Password cannot exceed 72 characters
|
||||||
|
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||||
|
password_same_as_current: New password must be different from the current password
|
||||||
|
password_mismatch: Passwords do not match
|
||||||
|
password_updated: Password updated.
|
||||||
|
password_update_failed: Unable to update password
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Download not available
|
download_unavailable: Download not available
|
||||||
not_available: Replay not available
|
not_available: Replay not available
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
es:
|
es:
|
||||||
|
password_policy:
|
||||||
|
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
|
||||||
|
activerecord:
|
||||||
|
attributes:
|
||||||
|
user:
|
||||||
|
password: Contraseña
|
||||||
|
admin_account:
|
||||||
|
password: Contraseña
|
||||||
|
errors:
|
||||||
|
models:
|
||||||
|
user:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||||
|
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||||
|
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||||
|
admin_account:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||||
|
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||||
|
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||||
club:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Deporte principal
|
sport_label: Deporte principal
|
||||||
@@ -419,6 +441,8 @@ es:
|
|||||||
preview_waiting: Vista previa a la espera de señal…
|
preview_waiting: Vista previa a la espera de señal…
|
||||||
pause: Pausar directo
|
pause: Pausar directo
|
||||||
resume: Reanudar directo
|
resume: Reanudar directo
|
||||||
|
mute: Silenciar audio
|
||||||
|
unmute: Reactivar audio
|
||||||
stop: Finalizar directo
|
stop: Finalizar directo
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set ganado
|
set_won_title: Set ganado
|
||||||
@@ -454,9 +478,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
|
||||||
@@ -600,12 +627,29 @@ es:
|
|||||||
welcome_back: "¡Bienvenido de nuevo!"
|
welcome_back: "¡Bienvenido de nuevo!"
|
||||||
invalid_credentials: Correo o contraseña no válidos
|
invalid_credentials: Correo o contraseña no válidos
|
||||||
logged_out: Sesión cerrada
|
logged_out: Sesión cerrada
|
||||||
|
unauthorized: No autorizado
|
||||||
|
invalid_token: Token no válido
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña.
|
email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña.
|
||||||
invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña.
|
invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña.
|
||||||
password_min_length: La contraseña debe tener al menos 8 caracteres
|
password_min_length: La contraseña debe tener al menos 8 caracteres
|
||||||
|
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||||
|
password_too_long: La contraseña no puede superar los 72 caracteres
|
||||||
|
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||||
|
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||||
password_mismatch: Las contraseñas no coinciden
|
password_mismatch: Las contraseñas no coinciden
|
||||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||||
|
accounts:
|
||||||
|
name_required: El nombre es obligatorio
|
||||||
|
profile_updated: Perfil actualizado.
|
||||||
|
password_current_incorrect: La contraseña actual no es correcta
|
||||||
|
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||||
|
password_too_long: La contraseña no puede superar los 72 caracteres
|
||||||
|
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||||
|
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||||
|
password_mismatch: Las contraseñas no coinciden
|
||||||
|
password_updated: Contraseña actualizada.
|
||||||
|
password_update_failed: No se pudo actualizar la contraseña
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Descarga no disponible
|
download_unavailable: Descarga no disponible
|
||||||
not_available: Repetición no disponible
|
not_available: Repetición no disponible
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
fr:
|
fr:
|
||||||
|
password_policy:
|
||||||
|
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
|
||||||
|
activerecord:
|
||||||
|
attributes:
|
||||||
|
user:
|
||||||
|
password: Mot de passe
|
||||||
|
admin_account:
|
||||||
|
password: Mot de passe
|
||||||
|
errors:
|
||||||
|
models:
|
||||||
|
user:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "est trop court (minimum %{count} caractères)"
|
||||||
|
too_long: "est trop long (maximum %{count} caractères)"
|
||||||
|
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||||
|
admin_account:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "est trop court (minimum %{count} caractères)"
|
||||||
|
too_long: "est trop long (maximum %{count} caractères)"
|
||||||
|
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||||
club:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Sport principal
|
sport_label: Sport principal
|
||||||
@@ -419,6 +441,8 @@ fr:
|
|||||||
preview_waiting: Aperçu en attente du signal…
|
preview_waiting: Aperçu en attente du signal…
|
||||||
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
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set gagné
|
set_won_title: Set gagné
|
||||||
@@ -454,9 +478,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
|
||||||
@@ -600,12 +627,29 @@ fr:
|
|||||||
welcome_back: Bon retour !
|
welcome_back: Bon retour !
|
||||||
invalid_credentials: E-mail ou mot de passe invalide
|
invalid_credentials: E-mail ou mot de passe invalide
|
||||||
logged_out: Déconnecté
|
logged_out: Déconnecté
|
||||||
|
unauthorized: Non autorisé
|
||||||
|
invalid_token: Jeton invalide
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe.
|
email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe.
|
||||||
invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe.
|
invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe.
|
||||||
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
||||||
|
password_too_short: Le mot de passe doit comporter au moins 8 caractères
|
||||||
|
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
|
||||||
|
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||||
|
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||||
password_mismatch: Les mots de passe ne correspondent pas
|
password_mismatch: Les mots de passe ne correspondent pas
|
||||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||||
|
accounts:
|
||||||
|
name_required: Le nom est obligatoire
|
||||||
|
profile_updated: Profil mis à jour.
|
||||||
|
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||||
|
password_too_short: Le mot de passe doit contenir au moins 8 caractères
|
||||||
|
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
|
||||||
|
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||||
|
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||||
|
password_mismatch: Les mots de passe ne correspondent pas
|
||||||
|
password_updated: Mot de passe mis à jour.
|
||||||
|
password_update_failed: Impossible de mettre à jour le mot de passe
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Téléchargement non disponible
|
download_unavailable: Téléchargement non disponible
|
||||||
not_available: Replay non disponible
|
not_available: Replay non disponible
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
it:
|
it:
|
||||||
|
password_policy:
|
||||||
|
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
|
||||||
|
activerecord:
|
||||||
|
errors:
|
||||||
|
models:
|
||||||
|
user:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||||
|
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||||
|
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||||
|
admin_account:
|
||||||
|
attributes:
|
||||||
|
password:
|
||||||
|
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||||
|
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||||
|
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||||
club:
|
club:
|
||||||
back_to_club: "← Società"
|
back_to_club: "← Società"
|
||||||
sport_label: Sport principale
|
sport_label: Sport principale
|
||||||
@@ -419,6 +436,8 @@ it:
|
|||||||
preview_waiting: Anteprima in attesa del segnale…
|
preview_waiting: Anteprima in attesa del segnale…
|
||||||
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
|
||||||
modal:
|
modal:
|
||||||
set_won_title: Set vinto
|
set_won_title: Set vinto
|
||||||
@@ -454,9 +473,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
|
||||||
@@ -600,12 +622,34 @@ it:
|
|||||||
welcome_back: Bentornato!
|
welcome_back: Bentornato!
|
||||||
invalid_credentials: Email o password non validi
|
invalid_credentials: Email o password non validi
|
||||||
logged_out: Disconnesso
|
logged_out: Disconnesso
|
||||||
|
unauthorized: Non autorizzato
|
||||||
|
invalid_token: Token non valido
|
||||||
password_resets:
|
password_resets:
|
||||||
email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password.
|
email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password.
|
||||||
invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password.
|
invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password.
|
||||||
password_min_length: La password deve avere almeno 8 caratteri
|
password_min_length: La password deve avere almeno 8 caratteri
|
||||||
|
password_too_short: La password deve avere almeno 8 caratteri
|
||||||
|
password_too_long: La password non può superare i 72 caratteri
|
||||||
|
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||||
|
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||||
|
password_too_short: La password deve avere almeno 8 caratteri
|
||||||
|
password_too_long: La password non può superare i 72 caratteri
|
||||||
|
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||||
password_mismatch: Le password non coincidono
|
password_mismatch: Le password non coincidono
|
||||||
password_updated: Password aggiornata. Ora puoi accedere.
|
password_updated: Password aggiornata. Ora puoi accedere.
|
||||||
|
accounts:
|
||||||
|
name_required: Il nome è obbligatorio
|
||||||
|
profile_updated: Profilo aggiornato.
|
||||||
|
password_current_incorrect: La password attuale non è corretta
|
||||||
|
password_too_short: La password deve avere almeno 8 caratteri
|
||||||
|
password_too_long: La password non può superare i 72 caratteri
|
||||||
|
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||||
|
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||||
|
password_too_long: La password non può superare i 72 caratteri
|
||||||
|
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||||
|
password_mismatch: Le password non coincidono
|
||||||
|
password_updated: Password aggiornata.
|
||||||
|
password_update_failed: Impossibile aggiornare la password
|
||||||
replay:
|
replay:
|
||||||
download_unavailable: Download non disponibile
|
download_unavailable: Download non disponibile
|
||||||
not_available: Replay non disponibile
|
not_available: Replay non disponibile
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ de:
|
|||||||
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: Datenschutzdokument
|
s6_privacy_doc_link_text: Datenschutzdokument
|
||||||
manage_button: Cookie-Einstellungen verwalten
|
manage_button: Cookie-Einstellungen verwalten
|
||||||
|
support:
|
||||||
|
title: Match Live TV Support
|
||||||
|
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
|
||||||
|
h1: Match Live TV Support
|
||||||
|
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
|
||||||
|
email_label: "Support-E-Mail:"
|
||||||
|
access_title: Zugangsprobleme
|
||||||
|
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
|
||||||
|
live_title: Probleme während eines Livestreams
|
||||||
|
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
|
||||||
|
team_title: Teamverwaltung
|
||||||
|
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
|
||||||
|
privacy_title: Datenschutz
|
||||||
|
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
|
||||||
|
privacy_link_text: Datenschutz
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ en:
|
|||||||
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: privacy document
|
s6_privacy_doc_link_text: privacy document
|
||||||
manage_button: Manage cookie preferences
|
manage_button: Manage cookie preferences
|
||||||
|
support:
|
||||||
|
title: Match Live TV Support
|
||||||
|
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
|
||||||
|
h1: Match Live TV Support
|
||||||
|
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
|
||||||
|
email_label: "Support email:"
|
||||||
|
access_title: Access issues
|
||||||
|
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
|
||||||
|
live_title: Issues during a live stream
|
||||||
|
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
|
||||||
|
team_title: Team management
|
||||||
|
team_body: "Accounts and broadcasting permissions are managed by the sports club."
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
|
||||||
|
privacy_link_text: Privacy
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ es:
|
|||||||
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento de privacidad
|
s6_privacy_doc_link_text: documento de privacidad
|
||||||
manage_button: Gestionar preferencias de cookies
|
manage_button: Gestionar preferencias de cookies
|
||||||
|
support:
|
||||||
|
title: Soporte Match Live TV
|
||||||
|
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
|
||||||
|
h1: Soporte Match Live TV
|
||||||
|
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
|
||||||
|
email_label: "Correo de soporte:"
|
||||||
|
access_title: Problemas de acceso
|
||||||
|
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
|
||||||
|
live_title: Problemas durante un directo
|
||||||
|
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
|
||||||
|
team_title: Gestión del equipo
|
||||||
|
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
|
||||||
|
privacy_title: Privacidad
|
||||||
|
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
|
||||||
|
privacy_link_text: Privacidad
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ fr:
|
|||||||
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: document de confidentialité
|
s6_privacy_doc_link_text: document de confidentialité
|
||||||
manage_button: Gérer les préférences de cookies
|
manage_button: Gérer les préférences de cookies
|
||||||
|
support:
|
||||||
|
title: Support Match Live TV
|
||||||
|
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
|
||||||
|
h1: Support Match Live TV
|
||||||
|
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
|
||||||
|
email_label: "E-mail du support :"
|
||||||
|
access_title: Problèmes d'accès
|
||||||
|
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
|
||||||
|
live_title: Problèmes pendant un direct
|
||||||
|
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
|
||||||
|
team_title: Gestion de l'équipe
|
||||||
|
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
|
||||||
|
privacy_title: Confidentialité
|
||||||
|
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
|
||||||
|
privacy_link_text: Confidentialité
|
||||||
|
|||||||
@@ -195,3 +195,18 @@ it:
|
|||||||
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
||||||
s6_privacy_doc_link_text: documento privacy
|
s6_privacy_doc_link_text: documento privacy
|
||||||
manage_button: Gestisci preferenze cookie
|
manage_button: Gestisci preferenze cookie
|
||||||
|
support:
|
||||||
|
title: Supporto Match Live TV
|
||||||
|
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
|
||||||
|
h1: Supporto Match Live TV
|
||||||
|
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dell’app puoi contattare il nostro supporto."
|
||||||
|
email_label: "Email di supporto:"
|
||||||
|
access_title: Problemi di accesso
|
||||||
|
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
|
||||||
|
live_title: Problemi durante una diretta
|
||||||
|
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dall’app. Se il problema persiste, contatta il supporto indicando dispositivo, versione dell’app e una breve descrizione del problema."
|
||||||
|
team_title: Gestione della squadra
|
||||||
|
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
|
||||||
|
privacy_link_text: Privacy
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ de:
|
|||||||
my_team: Mein Team
|
my_team: Mein Team
|
||||||
login: Anmelden
|
login: Anmelden
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
|
account: Konto
|
||||||
signup: Team registrieren
|
signup: Team registrieren
|
||||||
footer:
|
footer:
|
||||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||||
@@ -102,7 +103,7 @@ de:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Neues Passwort — Match Live TV"
|
meta_title: "Neues Passwort — Match Live TV"
|
||||||
title: Neues Passwort wählen
|
title: Neues Passwort wählen
|
||||||
new_password_label: "Neues Passwort (min. 8 Zeichen)"
|
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||||
submit: Passwort speichern
|
submit: Passwort speichern
|
||||||
back_to_login: Zurück zur Anmeldung
|
back_to_login: Zurück zur Anmeldung
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ de:
|
|||||||
signup_link: registrieren Sie sich
|
signup_link: registrieren Sie sich
|
||||||
mobile_app_label: "Mobile App:"
|
mobile_app_label: "Mobile App:"
|
||||||
open_in_app: Einladung in der App öffnen
|
open_in_app: Einladung in der App öffnen
|
||||||
|
account:
|
||||||
|
meta_title: "Dein Konto — Match Live TV"
|
||||||
|
meta_description: Name und Passwort deines Match Live TV-Kontos verwalten.
|
||||||
|
title: Dein Konto
|
||||||
|
profile_heading: Profil
|
||||||
|
password_heading: Passwort ändern
|
||||||
|
name_label: Name
|
||||||
|
role_label: "Rolle: %{role}"
|
||||||
|
current_password_label: Aktuelles Passwort
|
||||||
|
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||||
|
save_profile: Profil speichern
|
||||||
|
save_password: Passwort aktualisieren
|
||||||
common:
|
common:
|
||||||
privacy: Datenschutz
|
privacy: Datenschutz
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: AGB
|
terms: AGB
|
||||||
pricing: Preise
|
pricing: Preise
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ en:
|
|||||||
my_team: My team
|
my_team: My team
|
||||||
login: Log in
|
login: Log in
|
||||||
logout: Log out
|
logout: Log out
|
||||||
|
account: Account
|
||||||
signup: Register a team
|
signup: Register a team
|
||||||
footer:
|
footer:
|
||||||
tagline: Every match, every event, for those who can't be there
|
tagline: Every match, every event, for those who can't be there
|
||||||
@@ -102,7 +103,7 @@ en:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "New password — Match Live TV"
|
meta_title: "New password — Match Live TV"
|
||||||
title: Choose a new password
|
title: Choose a new password
|
||||||
new_password_label: "New password (min. 8 characters)"
|
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||||
submit: Save password
|
submit: Save password
|
||||||
back_to_login: Back to login
|
back_to_login: Back to login
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ en:
|
|||||||
signup_link: sign up
|
signup_link: sign up
|
||||||
mobile_app_label: "Mobile app:"
|
mobile_app_label: "Mobile app:"
|
||||||
open_in_app: Open invitation in the app
|
open_in_app: Open invitation in the app
|
||||||
|
account:
|
||||||
|
meta_title: "Your account — Match Live TV"
|
||||||
|
meta_description: Manage your Match Live TV account name and password.
|
||||||
|
title: Your account
|
||||||
|
profile_heading: Profile
|
||||||
|
password_heading: Change password
|
||||||
|
name_label: Name
|
||||||
|
role_label: "Role: %{role}"
|
||||||
|
current_password_label: Current password
|
||||||
|
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||||
|
save_profile: Save profile
|
||||||
|
save_password: Update password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Terms
|
terms: Terms
|
||||||
pricing: Pricing
|
pricing: Pricing
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ es:
|
|||||||
my_team: Mi equipo
|
my_team: Mi equipo
|
||||||
login: Acceder
|
login: Acceder
|
||||||
logout: Salir
|
logout: Salir
|
||||||
|
account: Cuenta
|
||||||
signup: Registrar equipo
|
signup: Registrar equipo
|
||||||
footer:
|
footer:
|
||||||
tagline: Cada partido, cada evento, para quien no puede estar
|
tagline: Cada partido, cada evento, para quien no puede estar
|
||||||
@@ -102,7 +103,7 @@ es:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nueva contraseña — Match Live TV"
|
meta_title: "Nueva contraseña — Match Live TV"
|
||||||
title: Elige una nueva contraseña
|
title: Elige una nueva contraseña
|
||||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||||
submit: Guardar contraseña
|
submit: Guardar contraseña
|
||||||
back_to_login: Volver al inicio de sesión
|
back_to_login: Volver al inicio de sesión
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ es:
|
|||||||
signup_link: regístrate
|
signup_link: regístrate
|
||||||
mobile_app_label: "App móvil:"
|
mobile_app_label: "App móvil:"
|
||||||
open_in_app: Abrir invitación en la app
|
open_in_app: Abrir invitación en la app
|
||||||
|
account:
|
||||||
|
meta_title: "Tu cuenta — Match Live TV"
|
||||||
|
meta_description: Gestiona el nombre y la contraseña de tu cuenta Match Live TV.
|
||||||
|
title: Tu cuenta
|
||||||
|
profile_heading: Perfil
|
||||||
|
password_heading: Cambiar contraseña
|
||||||
|
name_label: Nombre
|
||||||
|
role_label: "Rol: %{role}"
|
||||||
|
current_password_label: Contraseña actual
|
||||||
|
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||||
|
save_profile: Guardar perfil
|
||||||
|
save_password: Actualizar contraseña
|
||||||
common:
|
common:
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
|
support: Soporte
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Términos
|
terms: Términos
|
||||||
pricing: Precios
|
pricing: Precios
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ fr:
|
|||||||
my_team: Mon équipe
|
my_team: Mon équipe
|
||||||
login: Connexion
|
login: Connexion
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
|
account: Compte
|
||||||
signup: Inscrire une équipe
|
signup: Inscrire une équipe
|
||||||
footer:
|
footer:
|
||||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||||
@@ -102,7 +103,7 @@ fr:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nouveau mot de passe — Match Live TV"
|
meta_title: "Nouveau mot de passe — Match Live TV"
|
||||||
title: Choisissez un nouveau mot de passe
|
title: Choisissez un nouveau mot de passe
|
||||||
new_password_label: "Nouveau mot de passe (8 caractères min.)"
|
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||||
submit: Enregistrer le mot de passe
|
submit: Enregistrer le mot de passe
|
||||||
back_to_login: Retour à la connexion
|
back_to_login: Retour à la connexion
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ fr:
|
|||||||
signup_link: inscrivez-vous
|
signup_link: inscrivez-vous
|
||||||
mobile_app_label: "Application mobile :"
|
mobile_app_label: "Application mobile :"
|
||||||
open_in_app: Ouvrir l'invitation dans l'application
|
open_in_app: Ouvrir l'invitation dans l'application
|
||||||
|
account:
|
||||||
|
meta_title: "Votre compte — Match Live TV"
|
||||||
|
meta_description: Gérez le nom et le mot de passe de votre compte Match Live TV.
|
||||||
|
title: Votre compte
|
||||||
|
profile_heading: Profil
|
||||||
|
password_heading: Changer le mot de passe
|
||||||
|
name_label: Nom
|
||||||
|
role_label: "Rôle : %{role}"
|
||||||
|
current_password_label: Mot de passe actuel
|
||||||
|
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||||
|
save_profile: Enregistrer le profil
|
||||||
|
save_password: Mettre à jour le mot de passe
|
||||||
common:
|
common:
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
|
support: Support
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Conditions
|
terms: Conditions
|
||||||
pricing: Tarifs
|
pricing: Tarifs
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ it:
|
|||||||
my_team: La mia squadra
|
my_team: La mia squadra
|
||||||
login: Accedi
|
login: Accedi
|
||||||
logout: Esci
|
logout: Esci
|
||||||
|
account: Account
|
||||||
signup: Registra squadra
|
signup: Registra squadra
|
||||||
footer:
|
footer:
|
||||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||||
@@ -102,7 +103,7 @@ it:
|
|||||||
password_reset:
|
password_reset:
|
||||||
meta_title: "Nuova password — Match Live TV"
|
meta_title: "Nuova password — Match Live TV"
|
||||||
title: Scegli una nuova password
|
title: Scegli una nuova password
|
||||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||||
submit: Salva password
|
submit: Salva password
|
||||||
back_to_login: Torna al login
|
back_to_login: Torna al login
|
||||||
invitation:
|
invitation:
|
||||||
@@ -118,8 +119,21 @@ it:
|
|||||||
signup_link: registrati
|
signup_link: registrati
|
||||||
mobile_app_label: "App mobile:"
|
mobile_app_label: "App mobile:"
|
||||||
open_in_app: Apri invito nell'app
|
open_in_app: Apri invito nell'app
|
||||||
|
account:
|
||||||
|
meta_title: "Il tuo account — Match Live TV"
|
||||||
|
meta_description: Gestisci nome e password del tuo account Match Live TV.
|
||||||
|
title: Il tuo account
|
||||||
|
profile_heading: Profilo
|
||||||
|
password_heading: Cambia password
|
||||||
|
name_label: Nome
|
||||||
|
role_label: "Ruolo: %{role}"
|
||||||
|
current_password_label: Password attuale
|
||||||
|
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||||
|
save_profile: Salva profilo
|
||||||
|
save_password: Aggiorna password
|
||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
|
support: Supporto
|
||||||
cookies: Cookie
|
cookies: Cookie
|
||||||
terms: Termini
|
terms: Termini
|
||||||
pricing: Prezzi
|
pricing: Prezzi
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
|
|||||||
post "auth/logout", to: "auth#logout"
|
post "auth/logout", to: "auth#logout"
|
||||||
post "auth/refresh", to: "auth#refresh"
|
post "auth/refresh", to: "auth#refresh"
|
||||||
get "auth/me", to: "auth#me"
|
get "auth/me", to: "auth#me"
|
||||||
|
post "auth/password/forgot", to: "auth#forgot_password"
|
||||||
|
get "account", to: "accounts#show"
|
||||||
|
patch "account", to: "accounts#update"
|
||||||
|
patch "account/password", to: "accounts#password"
|
||||||
get "sports", to: "sports#index"
|
get "sports", to: "sports#index"
|
||||||
|
|
||||||
get "invitations/:token", to: "invitations#show"
|
get "invitations/:token", to: "invitations#show"
|
||||||
@@ -43,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
|
||||||
@@ -138,6 +143,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
|
||||||
|
|
||||||
root to: "public/pages#home"
|
root to: "public/pages#home"
|
||||||
@@ -150,6 +156,7 @@ Rails.application.routes.draw do
|
|||||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||||
get "pricing", to: redirect("/prezzi")
|
get "pricing", to: redirect("/prezzi")
|
||||||
get "privacy", to: "pages#privacy", as: :privacy
|
get "privacy", to: "pages#privacy", as: :privacy
|
||||||
|
get "support", to: "pages#support", as: :support
|
||||||
get "cookie", to: "pages#cookies", as: :cookies
|
get "cookie", to: "pages#cookies", as: :cookies
|
||||||
get "cookies", to: redirect("/cookie")
|
get "cookies", to: redirect("/cookie")
|
||||||
get "termini", to: "pages#terms", as: :termini
|
get "termini", to: "pages#terms", as: :termini
|
||||||
@@ -163,6 +170,10 @@ Rails.application.routes.draw do
|
|||||||
post "password/forgot", to: "password_resets#create"
|
post "password/forgot", to: "password_resets#create"
|
||||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||||
patch "password/reset", to: "password_resets#update"
|
patch "password/reset", to: "password_resets#update"
|
||||||
|
get "account", to: "accounts#show", as: :account
|
||||||
|
patch "account", to: "accounts#update"
|
||||||
|
get "account/password", to: redirect("/account"), as: nil
|
||||||
|
patch "account/password", to: "accounts#update_password", as: :account_password
|
||||||
get "clubs/new", to: "clubs#new", as: :new_club
|
get "clubs/new", to: "clubs#new", as: :new_club
|
||||||
post "clubs", to: "clubs#create"
|
post "clubs", to: "clubs#create"
|
||||||
get "clubs/:id", to: "clubs#show", as: :club
|
get "clubs/:id", to: "clubs#show", as: :club
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddAudioMutedToStreamSessions < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :stream_sessions, :audio_muted, :boolean, default: false, null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+2
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_12_220000) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -280,6 +280,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
t.datetime "regia_token_expires_at"
|
||||||
|
t.boolean "audio_muted", default: false, null: false
|
||||||
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
|
|||||||
+4
-4
@@ -1,18 +1,18 @@
|
|||||||
load Rails.root.join("db/seeds/plans.rb")
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
|
||||||
AdminAccount.find_or_create_by!(username: "admin") do |a|
|
AdminAccount.find_or_create_by!(username: "admin") do |a|
|
||||||
a.password = "admin"
|
a.password = "AdminPass123"
|
||||||
end
|
end
|
||||||
|
|
||||||
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
||||||
u.name = "Coach Demo"
|
u.name = "Coach Demo"
|
||||||
u.password = "password123"
|
u.password = "Password123"
|
||||||
u.role = "coach"
|
u.role = "coach"
|
||||||
end
|
end
|
||||||
|
|
||||||
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
||||||
u.name = "Admin"
|
u.name = "Admin"
|
||||||
u.password = "password123"
|
u.password = "Password123"
|
||||||
u.role = "admin"
|
u.role = "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -41,5 +41,5 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m
|
|||||||
m.phase = "Semifinale"
|
m.phase = "Semifinale"
|
||||||
end
|
end
|
||||||
|
|
||||||
puts "Seed OK: coach@matchlivetv.test / password123"
|
puts "Seed OK: coach@matchlivetv.test / Password123"
|
||||||
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls",
|
||||||
|
"delegate_permission/common.get_login_creds"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.matchlivetv.match_live_tv",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||||
|
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
+107
-16
@@ -199,6 +199,17 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
body.nav-menu-open .site-chrome {
|
body.nav-menu-open .site-chrome {
|
||||||
z-index: 1300;
|
z-index: 1300;
|
||||||
}
|
}
|
||||||
|
/* Keep the close control above the full-screen sheet; hide duplicate mast brand. */
|
||||||
|
body.nav-menu-open .site-masthead {
|
||||||
|
z-index: 1320;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
body.nav-menu-open .mast-brand {
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
.nav-backdrop {
|
.nav-backdrop {
|
||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -236,16 +247,50 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.nav-panel {
|
.nav-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
|
min-height: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 72px 24px 32px;
|
padding: 16px 24px 32px;
|
||||||
padding-top: calc(72px + env(safe-area-inset-top, 0px));
|
padding-top: calc(16px + env(safe-area-inset-top, 0px));
|
||||||
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px));
|
||||||
|
overflow-x: hidden;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
.nav-mobile-head {
|
||||||
|
order: -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
padding: 0 52px 16px 0; /* room for the close (X) control on the right */
|
||||||
|
border-bottom: 1px solid #252530;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-mobile-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
text-decoration: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-mobile-brand:hover { text-decoration: none; opacity: 0.92; }
|
||||||
|
.nav-mobile-brand .brand { font-size: 1.05rem; }
|
||||||
|
.nav-mobile-brand-logo {
|
||||||
|
display: block;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.nav-panel > a,
|
.nav-panel > a,
|
||||||
.nav-panel .nav-link-item {
|
.nav-panel .nav-link-item {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -263,11 +308,13 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
flex-wrap: nowrap;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 14px;
|
gap: 0;
|
||||||
margin-top: 24px;
|
margin-top: 8px;
|
||||||
padding: 24px 0 0;
|
padding: 0;
|
||||||
border-top: 1px solid #252530;
|
border-top: none;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.nav-actions .nav-link-item {
|
.nav-actions .nav-link-item {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -289,10 +336,14 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.nav-lang {
|
.nav-lang {
|
||||||
margin-left: 0;
|
margin: 0;
|
||||||
margin-top: 4px;
|
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
width: 100%;
|
width: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-lang .lang-switcher__menu {
|
||||||
|
/* Keep the list inside the open mobile sheet */
|
||||||
|
z-index: 1320;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,14 +393,22 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px 20px;
|
gap: 8px 20px;
|
||||||
}
|
}
|
||||||
|
.nav-mobile-head {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
.nav-mobile-brand {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-lang {
|
||||||
|
order: 2;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
.nav-actions {
|
.nav-actions {
|
||||||
|
order: 1;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
.nav-lang {
|
|
||||||
margin-left: 2px;
|
|
||||||
}
|
|
||||||
.nav-backdrop { display: none !important; }
|
.nav-backdrop { display: none !important; }
|
||||||
}
|
}
|
||||||
.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; }
|
.btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; }
|
||||||
@@ -1230,7 +1289,22 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.hero-split .hero-cta { flex-direction: column; }
|
.hero-split .hero-cta { flex-direction: column; }
|
||||||
.hero-split .hero-cta .btn { width: 100%; text-align: center; }
|
.hero-split .hero-cta .btn { width: 100%; text-align: center; }
|
||||||
}
|
}
|
||||||
.section { padding: 40px 0; }
|
.section {
|
||||||
|
padding-top: 40px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
/* Keep horizontal inset when .section shares a node with .wrap (padding shorthand must not win). */
|
||||||
|
.section.wrap {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
.table-scroll {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
.section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; }
|
.section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; }
|
||||||
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
|
.steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; }
|
||||||
.step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; }
|
.step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; }
|
||||||
@@ -1363,7 +1437,7 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
|
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
|
||||||
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
||||||
.site-footer__legal p:last-child { margin-bottom: 0; }
|
.site-footer__legal p:last-child { margin-bottom: 0; }
|
||||||
.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; }
|
.compare-table { width: 100%; min-width: 520px; border-collapse: collapse; margin-top: 0; font-size: 0.9rem; }
|
||||||
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||||
.compare-table th { color: #aaa; font-weight: 600; }
|
.compare-table th { color: #aaa; font-weight: 600; }
|
||||||
.billing-documents h2 { margin-top: 0; }
|
.billing-documents h2 { margin-top: 0; }
|
||||||
@@ -1372,11 +1446,14 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
.card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||||
|
|
||||||
.seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; }
|
.seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; }
|
||||||
.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; }
|
.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; text-align: left; }
|
||||||
.seo-prose h2:first-child { margin-top: 0; }
|
.seo-prose h2:first-child { margin-top: 0; }
|
||||||
.seo-prose p { margin: 0 0 14px; }
|
.seo-prose p { margin: 0 0 14px; }
|
||||||
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
|
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
|
||||||
.seo-prose a { color: #e53935; }
|
.seo-prose a { color: #e53935; }
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.seo-prose h2 { font-size: 1.15rem; line-height: 1.35; }
|
||||||
|
}
|
||||||
.seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; }
|
.seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; }
|
||||||
.faq-list { max-width: 720px; margin: 0 auto; }
|
.faq-list { max-width: 720px; margin: 0 auto; }
|
||||||
.faq-item {
|
.faq-item {
|
||||||
@@ -1412,7 +1489,8 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.legal-doc a { color: #e53935; }
|
.legal-doc a { color: #e53935; }
|
||||||
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
|
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
|
||||||
.legal-back { margin-top: 32px; }
|
.legal-back { margin-top: 32px; }
|
||||||
.legal-table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; font-size: 0.88rem; }
|
.legal-doc .table-scroll { margin: 12px 0 16px; }
|
||||||
|
.legal-table { width: 100%; min-width: 560px; border-collapse: collapse; margin: 0; font-size: 0.88rem; }
|
||||||
.legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; }
|
.legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||||
.legal-table th { background: #14141c; color: #ccc; }
|
.legal-table th { background: #14141c; color: #ccc; }
|
||||||
.legal-accept {
|
.legal-accept {
|
||||||
@@ -1447,6 +1525,19 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.auth-forgot a { color: #e53935; }
|
.auth-forgot a { color: #e53935; }
|
||||||
table.data { width: 100%; border-collapse: collapse; }
|
table.data { width: 100%; border-collapse: collapse; }
|
||||||
table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||||
|
/* Wide roster/match tables: scroll inside the card instead of expanding the page. */
|
||||||
|
@media (max-width: 899px) {
|
||||||
|
.card:has(table.data),
|
||||||
|
.team-streaming-staff:has(table.data),
|
||||||
|
.billing-documents:has(table.data) {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
table.data {
|
||||||
|
min-width: 520px;
|
||||||
|
}
|
||||||
|
}
|
||||||
input, select {
|
input, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|||||||
@@ -165,6 +165,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.regia-btn--outline.is-muted {
|
||||||
|
border-color: var(--regia-yellow);
|
||||||
|
color: var(--regia-yellow);
|
||||||
|
}
|
||||||
.regia-btn--danger {
|
.regia-btn--danger {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid var(--regia-red);
|
border: 1px solid var(--regia-red);
|
||||||
|
|||||||
@@ -25,9 +25,12 @@
|
|||||||
partialsPrefix: "Parziali:",
|
partialsPrefix: "Parziali:",
|
||||||
resumed: "Diretta ripresa",
|
resumed: "Diretta ripresa",
|
||||||
pausedCover: "Diretta in pausa — copertina in onda",
|
pausedCover: "Diretta in pausa — copertina in onda",
|
||||||
|
muted: "Audio silenziato — YouTube non riceve il microfono",
|
||||||
|
unmuted: "Audio microfono riattivato",
|
||||||
closed: "Diretta chiusa",
|
closed: "Diretta chiusa",
|
||||||
resumeError: "Errore ripresa diretta",
|
resumeError: "Errore ripresa diretta",
|
||||||
pauseError: "Errore pausa diretta",
|
pauseError: "Errore pausa diretta",
|
||||||
|
muteError: "Errore silenziamento audio",
|
||||||
closeError: "Errore chiusura",
|
closeError: "Errore chiusura",
|
||||||
closeConfirm: "Chiudere definitivamente la diretta?",
|
closeConfirm: "Chiudere definitivamente la diretta?",
|
||||||
linkUnavailable: "Link non disponibile",
|
linkUnavailable: "Link non disponibile",
|
||||||
@@ -39,6 +42,8 @@
|
|||||||
streamEnded: "Diretta terminata",
|
streamEnded: "Diretta terminata",
|
||||||
resumeLabel: "Riprendi diretta",
|
resumeLabel: "Riprendi diretta",
|
||||||
pauseLabel: "Metti in pausa",
|
pauseLabel: "Metti in pausa",
|
||||||
|
muteLabel: "Silenzia audio",
|
||||||
|
unmuteLabel: "Riattiva audio",
|
||||||
endedBadge: "Terminata",
|
endedBadge: "Terminata",
|
||||||
pausedBadge: "In pausa",
|
pausedBadge: "In pausa",
|
||||||
liveBadge: "In onda",
|
liveBadge: "In onda",
|
||||||
@@ -76,6 +81,7 @@
|
|||||||
scoreUrl: root.dataset.scoreUrl,
|
scoreUrl: root.dataset.scoreUrl,
|
||||||
pauseUrl: root.dataset.pauseUrl,
|
pauseUrl: root.dataset.pauseUrl,
|
||||||
resumeUrl: root.dataset.resumeUrl,
|
resumeUrl: root.dataset.resumeUrl,
|
||||||
|
audioMuteUrl: root.dataset.audioMuteUrl,
|
||||||
stopUrl: root.dataset.stopUrl,
|
stopUrl: root.dataset.stopUrl,
|
||||||
cableUrl: root.dataset.cableUrl,
|
cableUrl: root.dataset.cableUrl,
|
||||||
hlsUrl: root.dataset.hlsUrl,
|
hlsUrl: root.dataset.hlsUrl,
|
||||||
@@ -103,6 +109,7 @@
|
|||||||
preview: document.getElementById("regia-preview"),
|
preview: document.getElementById("regia-preview"),
|
||||||
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
previewPlaceholder: document.getElementById("regia-preview-placeholder"),
|
||||||
btnPause: document.getElementById("btn-pause"),
|
btnPause: document.getElementById("btn-pause"),
|
||||||
|
btnAudioMute: document.getElementById("btn-audio-mute"),
|
||||||
subtitle: document.getElementById("regia-subtitle")
|
subtitle: document.getElementById("regia-subtitle")
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -111,6 +118,7 @@
|
|||||||
let previewStarted = false;
|
let previewStarted = false;
|
||||||
let wasPausedPreview = false;
|
let wasPausedPreview = false;
|
||||||
let streamPaused = root.dataset.initialPaused === "true";
|
let streamPaused = root.dataset.initialPaused === "true";
|
||||||
|
let audioMuted = root.dataset.initialAudioMuted === "true";
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
function hlsUrlFresh() {
|
||||||
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
|
||||||
@@ -246,8 +254,18 @@
|
|||||||
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function syncMuteButton(data) {
|
||||||
|
if (!els.btnAudioMute) return;
|
||||||
|
if (typeof data.audio_muted === "boolean") {
|
||||||
|
audioMuted = data.audio_muted;
|
||||||
|
}
|
||||||
|
els.btnAudioMute.textContent = audioMuted ? I18N.unmuteLabel : I18N.muteLabel;
|
||||||
|
els.btnAudioMute.classList.toggle("is-muted", audioMuted);
|
||||||
|
}
|
||||||
|
|
||||||
function setBadge(data) {
|
function setBadge(data) {
|
||||||
syncPauseButton(data);
|
syncPauseButton(data);
|
||||||
|
syncMuteButton(data);
|
||||||
if (data.stream_closed) {
|
if (data.stream_closed) {
|
||||||
els.badge.textContent = I18N.endedBadge;
|
els.badge.textContent = I18N.endedBadge;
|
||||||
els.badge.className = "regia-badge regia-badge--ended";
|
els.badge.className = "regia-badge regia-badge--ended";
|
||||||
@@ -458,6 +476,24 @@
|
|||||||
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
toast(wasPaused ? I18N.resumed : I18N.pausedCover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleMuteAudio() {
|
||||||
|
if (!cfg.audioMuteUrl) return;
|
||||||
|
const nextMuted = !audioMuted;
|
||||||
|
const res = await fetch(cfg.audioMuteUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||||
|
body: JSON.stringify({ muted: nextMuted })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || I18N.muteError);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
applyScorePayload(data);
|
||||||
|
setBadge(data);
|
||||||
|
toast(audioMuted ? I18N.muted : I18N.unmuted);
|
||||||
|
}
|
||||||
|
|
||||||
async function stopStream() {
|
async function stopStream() {
|
||||||
const ok = confirm(I18N.closeConfirm);
|
const ok = confirm(I18N.closeConfirm);
|
||||||
if (!ok) return null;
|
if (!ok) return null;
|
||||||
@@ -471,6 +507,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message)));
|
||||||
|
els.btnAudioMute?.addEventListener("click", () => toggleMuteAudio().catch((e) => toast(e.message)));
|
||||||
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message)));
|
||||||
|
|
||||||
async function shareLink(url, title, text) {
|
async function shareLink(url, title, text) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe PasswordComplexity do
|
||||||
|
describe ".violation" do
|
||||||
|
it "accepts a password with 3 character classes" do
|
||||||
|
expect(described_class.violation("NewPass123")).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accepts symbols instead of one letter class" do
|
||||||
|
expect(described_class.violation("newpass1!")).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects passwords that are too short" do
|
||||||
|
expect(described_class.violation("Ab1!")).to eq(:too_short)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects passwords with fewer than 3 classes" do
|
||||||
|
expect(described_class.violation("password123")).to eq(:too_weak)
|
||||||
|
expect(described_class.violation("PASSWORD123")).to eq(:too_weak)
|
||||||
|
expect(described_class.violation("Password")).to eq(:too_weak)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe ".same_as_current?" do
|
||||||
|
let!(:user) { User.create!(email: "same@example.com", name: "Same", password: "Password123", role: "coach") }
|
||||||
|
|
||||||
|
it "detects when the new password matches the current one" do
|
||||||
|
expect(described_class.same_as_current?(user, "Password123")).to eq(true)
|
||||||
|
expect(described_class.same_as_current?(user, "OtherPass123")).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "User validation" do
|
||||||
|
it "blocks weak passwords on create" do
|
||||||
|
user = User.new(email: "weak@example.com", name: "Weak", password: "password123", role: "coach")
|
||||||
|
expect(user).not_to be_valid
|
||||||
|
expect(user.errors[:password]).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allows strong passwords on create" do
|
||||||
|
user = User.new(email: "strong@example.com", name: "Strong", password: "NewPass123", role: "coach")
|
||||||
|
expect(user).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Account API", type: :request do
|
||||||
|
let!(:user) { User.create!(email: "account@example.com", name: "Account User", password: "Password123", role: "coach") }
|
||||||
|
let(:auth_headers) do
|
||||||
|
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||||
|
token = JSON.parse(response.body).fetch("access_token")
|
||||||
|
{ "Authorization" => "Bearer #{token}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/v1/account" do
|
||||||
|
it "returns the current user profile" do
|
||||||
|
get "/api/v1/account", headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body).to include("email" => user.email, "name" => "Account User", "role" => "coach")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "requires authentication" do
|
||||||
|
get "/api/v1/account"
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PATCH /api/v1/account" do
|
||||||
|
it "updates the name" do
|
||||||
|
patch "/api/v1/account", params: { name: "Nuovo Nome" }, headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(JSON.parse(response.body)["name"]).to eq("Nuovo Nome")
|
||||||
|
expect(user.reload.name).to eq("Nuovo Nome")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects a blank name" do
|
||||||
|
patch "/api/v1/account", params: { name: " " }, headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PATCH /api/v1/account/password" do
|
||||||
|
it "changes the password with the current password" do
|
||||||
|
patch "/api/v1/account/password",
|
||||||
|
params: {
|
||||||
|
current_password: "Password123",
|
||||||
|
password: "NewPass123",
|
||||||
|
password_confirmation: "NewPass123"
|
||||||
|
},
|
||||||
|
headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.authenticate("NewPass123")).to be_truthy
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects an incorrect current password" do
|
||||||
|
patch "/api/v1/account/password",
|
||||||
|
params: {
|
||||||
|
current_password: "wrong",
|
||||||
|
password: "NewPass123",
|
||||||
|
password_confirmation: "NewPass123"
|
||||||
|
},
|
||||||
|
headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects a password that fails complexity rules" do
|
||||||
|
patch "/api/v1/account/password",
|
||||||
|
params: {
|
||||||
|
current_password: "Password123",
|
||||||
|
password: "newpass123",
|
||||||
|
password_confirmation: "newpass123"
|
||||||
|
},
|
||||||
|
headers: auth_headers.merge("Accept-Language" => "en")
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(JSON.parse(response.body)["error"]).to match(/3 of/i)
|
||||||
|
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rejects reusing the current password" do
|
||||||
|
patch "/api/v1/account/password",
|
||||||
|
params: {
|
||||||
|
current_password: "Password123",
|
||||||
|
password: "Password123",
|
||||||
|
password_confirmation: "Password123"
|
||||||
|
},
|
||||||
|
headers: auth_headers.merge("Accept-Language" => "en")
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(JSON.parse(response.body)["error"]).to match(/different/i)
|
||||||
|
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||||
|
end
|
||||||
|
|
||||||
|
it "localizes password errors from Accept-Language" do
|
||||||
|
patch "/api/v1/account/password",
|
||||||
|
params: {
|
||||||
|
current_password: "Password123",
|
||||||
|
password: "Password123",
|
||||||
|
password_confirmation: "Password123"
|
||||||
|
},
|
||||||
|
headers: auth_headers.merge("Accept-Language" => "it")
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(JSON.parse(response.body)["error"]).to eq("La nuova password deve essere diversa da quella attuale")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "POST /api/v1/auth/password/forgot" do
|
||||||
|
it "always returns ok and sends mail when the user exists" do
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||||
|
}.to change { ActionMailer::Base.deliveries.size }.by(1)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns the same message for unknown emails" do
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
||||||
|
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "localizes the response message from Accept-Language" do
|
||||||
|
post "/api/v1/auth/password/forgot",
|
||||||
|
params: { email: user.email },
|
||||||
|
headers: { "Accept-Language" => "fr" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(JSON.parse(response.body)["message"]).to include("réinitialiser")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Api::V1 stream session audio mute", type: :request do
|
||||||
|
let!(:user) { User.create!(email: "mute-api@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:owner) { club.club_memberships.create!(user: user, role: "owner") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
|
let!(:session) { StreamSession.create!(match: match, user: user, platform: "youtube", status: "live") }
|
||||||
|
|
||||||
|
def auth_headers
|
||||||
|
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||||
|
token = response.parsed_body["access_token"]
|
||||||
|
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
|
||||||
|
end
|
||||||
|
|
||||||
|
it "include audio_muted nello show della sessione" do
|
||||||
|
get "/api/v1/sessions/#{session.id}", headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "silenzia e riattiva l'audio via PATCH" do
|
||||||
|
patch "/api/v1/sessions/#{session.id}/audio_mute",
|
||||||
|
params: { muted: true }.to_json,
|
||||||
|
headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(true)
|
||||||
|
expect(session.reload.audio_muted).to eq(true)
|
||||||
|
|
||||||
|
patch "/api/v1/sessions/#{session.id}/audio_mute",
|
||||||
|
params: { muted: false }.to_json,
|
||||||
|
headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Auth API", type: :request do
|
RSpec.describe "Auth API", type: :request do
|
||||||
let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "password123", role: "coach") }
|
let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "Password123", role: "coach") }
|
||||||
|
|
||||||
it "logs in with valid credentials" do
|
it "logs in with valid credentials" do
|
||||||
post "/api/v1/auth/login", params: { email: user.email, password: "password123" }
|
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(JSON.parse(response.body)).to have_key("access_token")
|
expect(JSON.parse(response.body)).to have_key("access_token")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Public regia", type: :request do
|
RSpec.describe "Public regia", type: :request do
|
||||||
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") }
|
let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
@@ -148,6 +148,29 @@ RSpec.describe "Public regia", type: :request do
|
|||||||
expect(session.reload.status).to eq("connecting")
|
expect(session.reload.status).to eq("connecting")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "silenzia e riattiva l'audio della diretta" do
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
expect(session.audio_muted).to eq(false)
|
||||||
|
|
||||||
|
post public_regia_audio_mute_path(token), params: { muted: true }, as: :json
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(true)
|
||||||
|
expect(session.reload.audio_muted).to eq(true)
|
||||||
|
|
||||||
|
post public_regia_audio_mute_path(token), params: { muted: false }, as: :json
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.parsed_body["audio_muted"]).to eq(false)
|
||||||
|
expect(session.reload.audio_muted).to eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra il pulsante mute sulla pagina regia" do
|
||||||
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
get public_regia_path(token)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("Silenzia audio")
|
||||||
|
expect(response.body).to include("btn-audio-mute")
|
||||||
|
end
|
||||||
|
|
||||||
it "emette token valido anche oltre le 8 ore dalla partenza" do
|
it "emette token valido anche oltre le 8 ore dalla partenza" do
|
||||||
session.update!(started_at: 9.hours.ago)
|
session.update!(started_at: 9.hours.ago)
|
||||||
token = Sessions::RegiaAccess.new(session).issue_token!
|
token = Sessions::RegiaAccess.new(session).issue_token!
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Public support page", type: :request do
|
||||||
|
it "è pubblica, indicizzabile e mostra l'email di supporto configurata" do
|
||||||
|
get public_support_path
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include(MatchLiveTv.support_email)
|
||||||
|
expect(response.body).to include("mailto:#{MatchLiveTv.support_email}")
|
||||||
|
expect(response.body).to include(I18n.t("legal.support.h1", locale: :it))
|
||||||
|
expect(response.body).to include('rel="canonical"')
|
||||||
|
expect(response.body).to include(public_support_path)
|
||||||
|
expect(response.body).to include(public_privacy_path)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non espone CTA o link commerciali (App Store Review)" do
|
||||||
|
get public_support_path
|
||||||
|
body = response.body
|
||||||
|
|
||||||
|
expect(body).not_to include(public_prezzi_path)
|
||||||
|
expect(body).not_to include(public_signup_path)
|
||||||
|
expect(body).not_to include('href="/prezzi"')
|
||||||
|
expect(body).not_to include('href="/signup"')
|
||||||
|
expect(body).not_to include(I18n.t("nav.signup", locale: :it))
|
||||||
|
expect(body).not_to include(I18n.t("nav.pricing", locale: :it))
|
||||||
|
expect(body).not_to include(I18n.t("common.pricing", locale: :it))
|
||||||
|
expect(body).not_to match(/\bPremium Light\b/i)
|
||||||
|
expect(body).not_to match(/\bPremium Full\b/i)
|
||||||
|
expect(body).not_to match(/\bpiano Free\b/i)
|
||||||
|
expect(body).not_to match(/\bAbbonati\b/i)
|
||||||
|
expect(body).not_to match(/\bAcquista\b/i)
|
||||||
|
end
|
||||||
|
|
||||||
|
{
|
||||||
|
"it" => "Supporto Match Live TV",
|
||||||
|
"en" => "Match Live TV Support",
|
||||||
|
"fr" => "Support Match Live TV",
|
||||||
|
"de" => "Match Live TV Support",
|
||||||
|
"es" => "Soporte Match Live TV"
|
||||||
|
}.each do |locale, heading|
|
||||||
|
it "renderizza correttamente in #{locale} senza translation missing" do
|
||||||
|
cookies[:mltv_locale] = locale
|
||||||
|
get public_support_path
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include(heading)
|
||||||
|
expect(response.body).not_to include("translation missing")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "include /support nella sitemap" do
|
||||||
|
get "/sitemap.xml"
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/support")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Sessions::SetAudioMute do
|
||||||
|
let!(:user) { User.create!(email: "mute@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
|
||||||
|
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") }
|
||||||
|
|
||||||
|
it "attiva il mute e persiste lo stato" do
|
||||||
|
described_class.new(session, muted: true).call
|
||||||
|
expect(session.reload.audio_muted).to eq(true)
|
||||||
|
expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_muted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "disattiva il mute" do
|
||||||
|
session.update!(audio_muted: true)
|
||||||
|
described_class.new(session, muted: false).call
|
||||||
|
expect(session.reload.audio_muted).to eq(false)
|
||||||
|
expect(session.stream_events.order(:occurred_at).last.event_type).to eq("audio_unmuted")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "è idempotente se lo stato è già quello richiesto" do
|
||||||
|
described_class.new(session, muted: false).call
|
||||||
|
expect(session.stream_events.count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Streams::YoutubeRelay do
|
||||||
|
describe ".youtube_ffmpeg_args (private)" do
|
||||||
|
def args(mode, url)
|
||||||
|
described_class.send(:youtube_ffmpeg_args, [mode, url], "rtmps://a.rtmps.youtube.com/live2/key")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "remuxes RTMP with video and audio copy (no AAC re-encode)" do
|
||||||
|
cmd = args(:rtmp, "rtmp://mediamtx:1935/live/match_x")
|
||||||
|
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-f", "flv")
|
||||||
|
expect(cmd).not_to include("aac")
|
||||||
|
expect(cmd.join(" ")).not_to include("-b:a")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "remuxes HLS with AAC bitstream filter for FLV" do
|
||||||
|
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
|
||||||
|
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
|
||||||
|
expect(cmd).not_to include("-b:a")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Android App Links / Digital Asset Links
|
||||||
|
|
||||||
|
Play Console verifica `https://www.matchlivetv.it/.well-known/assetlinks.json`.
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
- Servito da **edge** (nginx): `infra/nginx-edge/well-known/assetlinks.json`
|
||||||
|
- Specchio in Rails public: `backend/public/.well-known/assetlinks.json`
|
||||||
|
|
||||||
|
Package: `com.matchlivetv.match_live_tv`
|
||||||
|
Path App Links: `https://www.matchlivetv.it/join…` (`pathPrefix="/join"`)
|
||||||
|
|
||||||
|
Nel JSON ci sono:
|
||||||
|
|
||||||
|
1. SHA-256 del **upload key** (`native/android/keystore/matchlivetv-upload.jks`)
|
||||||
|
2. SHA-256 del certificato **App signing** di Play (da Play Console → Integrità dell’app)
|
||||||
|
|
||||||
|
Relazioni richieste da Play per la condivisione credenziali:
|
||||||
|
|
||||||
|
- `delegate_permission/common.handle_all_urls`
|
||||||
|
- `delegate_permission/common.get_login_creds`
|
||||||
|
|
||||||
|
Se Play rigenera il JSON, sostituisci `infra/nginx-edge/well-known/assetlinks.json` (e lo specchio in `backend/public/.well-known/`) e ricarica edge.
|
||||||
|
## Deploy edge (senza rebuild Rails)
|
||||||
|
|
||||||
|
Sul server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/matchlivetv/infra
|
||||||
|
# dopo git pull del repo
|
||||||
|
docker compose -f docker-compose.prod.yml --env-file .env up -d edge
|
||||||
|
curl -sS -D- https://www.matchlivetv.it/.well-known/assetlinks.json | head
|
||||||
|
```
|
||||||
|
|
||||||
|
Verifica Google:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://www.matchlivetv.it&relation=delegate_permission/common.handle_all_urls"
|
||||||
|
```
|
||||||
|
|
||||||
|
Poi in Play Console → Link diretti → ripeti i controlli dominio.
|
||||||
@@ -38,16 +38,16 @@ Solo se la società vuole il **proprio** canale invece di Match Live TV:
|
|||||||
|
|
||||||
Nella home app (**Partite**):
|
Nella home app (**Partite**):
|
||||||
|
|
||||||
- **Partita programmata** — scegli dal calendario una gara già inserita (sito o app)
|
|
||||||
- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario
|
- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario
|
||||||
|
- Oppure tocca una gara già in calendario (sito o app)
|
||||||
|
|
||||||
Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmissione → …).
|
Poi conferma dal foglio o tocca la card: si apre il wizard (Partita → Trasmissione → …).
|
||||||
|
|
||||||
## 4. Avvia diretta
|
## 4. Avvia diretta
|
||||||
|
|
||||||
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
|
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
|
||||||
2. Completa i passi → **Camera**
|
2. Completa i passi → **Camera**
|
||||||
3. Il telefono invia RTMP a MediaMTX; il server avvia **automaticamente** overlay (tabellone + logo) e RTMPS verso YouTube — nessun intervento manuale
|
3. Il telefono invia RTMP a MediaMTX (con overlay tabellone già nel flusso, se attivo); il server avvia **automaticamente** il relay RTMPS verso YouTube (`Streams::YoutubeRelay`) — nessun intervento manuale
|
||||||
|
|
||||||
## Sviluppo locale
|
## Sviluppo locale
|
||||||
|
|
||||||
@@ -67,9 +67,9 @@ Vedi [YOUTUBE_MORNING_CHECKLIST.md](./YOUTUBE_MORNING_CHECKLIST.md) — APK **1.
|
|||||||
| YouTube disabilitato | Premium Light o Full |
|
| YouTube disabilitato | Premium Light o Full |
|
||||||
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
|
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
|
||||||
| OAuth «app non verificata» | Utente di test Google + Avanzate |
|
| OAuth «app non verificata» | Utente di test Google + Avanzate |
|
||||||
| Live non su YouTube | Pipeline automatica (`Youtube::LivePipeline`): overlay → ingest active → activate. Controlla `docker logs infra-sidekiq-1` per `[OverlayRelay] started` e `[YoutubeBroadcastActivate]`. |
|
| Live non su YouTube | Pipeline `Youtube::LivePipeline` + `YoutubeRelay`. Controlla `docker logs infra-sidekiq-1` per `[YoutubeRelay] started` / `[YoutubeBroadcastActivate]`. |
|
||||||
| YouTube «In programma» / copertina | Nessun intervento manuale: entro ~20–90s il server avvia overlay (tee RTMPS) e attiva la broadcast quando ingest è `active`. |
|
| YouTube «In programma» / copertina | Entro ~20–90s il server avvia il relay RTMPS e attiva la broadcast quando l’ingest è pronto; in pausa MediaMTX manda la slate. |
|
||||||
| YouTube resta in «waiting» | `log/overlay_relay_<session_id>.log` in **sidekiq**; poll ogni 5s (`StreamPublisherSyncJob`). Attivazione ritenta ~6 min. |
|
| YouTube resta in «waiting» | Log `log/youtube_relay_*.log` in **sidekiq**; poll `StreamPublisherSyncJob`. Attivazione ritenta ~6 min. |
|
||||||
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
|
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
|
||||||
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
|
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
|
||||||
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |
|
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |
|
||||||
|
|||||||
+26
-19
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
||||||
|
|
||||||
**Ultimo aggiornamento:** 2026-06-14
|
**Ultimo aggiornamento:** 2026-07-29
|
||||||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -193,39 +193,41 @@ Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md) per pause, overlay e player
|
|||||||
### Flusso ingest
|
### Flusso ingest
|
||||||
|
|
||||||
```
|
```
|
||||||
App Android (RTMP 720p, AAC mono)
|
App (Android/iOS) RTMP 720p AAC mono
|
||||||
|
+ overlay GPU (tabellone/watermark) se overlay_kind ≠ none
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
MediaMTX :1935 — path live/match_{uuid} (grezzo, telefono)
|
MediaMTX :1935 — path live/match_{uuid}
|
||||||
│
|
|
||||||
▼ overlay ffmpeg (Streams::OverlayRelay)
|
|
||||||
path live/match_{uuid}_air (tabellone + badge bruciati nel video)
|
|
||||||
│
|
│
|
||||||
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
||||||
└──► ffmpeg -c copy ──► YouTube RTMP (Streams::YoutubeRelay, Premium)
|
└──► (solo platform=youtube)
|
||||||
|
Streams::YoutubeRelay in Sidekiq
|
||||||
|
ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS
|
||||||
```
|
```
|
||||||
|
|
||||||
| Componente | Ruolo |
|
| Componente | Ruolo |
|
||||||
|------------|--------|
|
|------------|--------|
|
||||||
| `Mediamtx::PathManager` | Crea path dinamici via API :9997 |
|
| Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) |
|
||||||
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
||||||
| `Streams::OverlayRelay` | Ricodifica con PNG 1280×720 aggiornata ogni ~2s |
|
| Overlay tabellone | **App nativa** (non più ricodifica server) |
|
||||||
| `Streams::YoutubeRelay` | Legge HLS da `mediamtx:8888` (non più via Rails) |
|
| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; remux copy A/V → YouTube |
|
||||||
| `Mediamtx::PublisherSync` | Stato publisher online/offline in Rails |
|
| `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off |
|
||||||
|
|
||||||
|
Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||||
|
|
||||||
### Pausa e continuità
|
### Pausa e continuità
|
||||||
|
|
||||||
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
||||||
2. MediaMTX passa alla slate sullo **stesso path** (stesso URL HLS).
|
2. MediaMTX passa alla slate sullo **stesso path** (stesso URL HLS).
|
||||||
3. YouTube relay continua sulla stessa uscita (vede slate).
|
3. Se attivo, YouTube relay continua sulla stessa uscita (vede slate).
|
||||||
4. Ripresa: `resume` → app ripubblica RTMP; MediaMTX concatena camera senza nuovo URL.
|
4. Ripresa: `resume` → app ripubblica RTMP; MediaMTX concatena camera senza nuovo URL.
|
||||||
|
|
||||||
**Recording inline MediaMTX disabilitato** (`record: false` su path live): il recorder al switch slate→camera distruggeva il muxer HLS. I replay usano job dedicato (vedi sotto).
|
**Recording:** path creato con `record: false`; acceso solo con publisher online e entitlement (`PublisherSync`). I replay usano job dedicato (vedi sotto).
|
||||||
|
|
||||||
### Pagina live web
|
### Pagina live web
|
||||||
|
|
||||||
- URL: `/live/:session_id`
|
- URL: `/live/:session_id`
|
||||||
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}_air/index.m3u8`
|
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}/index.m3u8`
|
||||||
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
|
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -278,8 +280,8 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails
|
|||||||
│
|
│
|
||||||
Sidekiq ◄── Redis ◄──────┘
|
Sidekiq ◄── Redis ◄──────┘
|
||||||
│
|
│
|
||||||
├── HealthMonitorJob, UploadJob, overlay refresh
|
├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg)
|
||||||
├── YoutubeRelay, OverlayRelay (ffmpeg)
|
├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube)
|
||||||
└── PublishToYoutubeJob, purge replay, ecc.
|
└── PublishToYoutubeJob, purge replay, ecc.
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -395,10 +397,13 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| Documento | Contenuto |
|
| Documento | Contenuto |
|
||||||
|-----------|-----------|
|
|-----------|-----------|
|
||||||
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
||||||
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pause, overlay, HLS.js |
|
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
|
||||||
|
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
|
||||||
|
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, mute audio, HLS, ruolo ffmpeg |
|
||||||
|
| [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md) | Mute microfono iOS (allineamento Mac) |
|
||||||
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
||||||
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
||||||
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Grafica in stream |
|
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) |
|
||||||
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
|
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
|
||||||
| [`native/android/README.md`](../native/android/README.md) | App Android |
|
| [`native/android/README.md`](../native/android/README.md) | App Android |
|
||||||
|
|
||||||
@@ -412,5 +417,7 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
|||||||
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
||||||
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
||||||
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
||||||
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube legge `mediamtx:8888` diretto |
|
| 2026-08 | Mute microfono in diretta (`audio_muted`) per evitare claim YouTube sulla musica in palestra |
|
||||||
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
||||||
|
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
|
||||||
|
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
# Gap Android → iOS: allineamento app nativa
|
# Gap Android → iOS: allineamento app nativa
|
||||||
|
|
||||||
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android `2.0.5-native`**.
|
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android**.
|
||||||
|
|
||||||
**Aggiornato:** 24 luglio 2026
|
**Aggiornato:** 12 agosto 2026
|
||||||
**Riferimento Android (produzione / telefono / Play):** `2.0.5-native` (`versionCode` **26**), API `https://www.matchlivetv.it`
|
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it`
|
||||||
**Stato iOS attuale:** marketing `2.0.5`, build `26` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast)
|
**Stato iOS attuale:** marketing `2.0.10`, build `31` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast, account/forgot)
|
||||||
|
|
||||||
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast) + bump versione, **senza** i bug di sessione/crash già risolti su Android 2.0.5.
|
**Nuovo (branch `feature/stream-audio-mute`):** mute microfono in diretta. Codice iOS già nel branch; verifica su Mac in [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md).
|
||||||
|
|
||||||
|
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast, account) + bump versione, **senza** i bug di sessione/crash già risolti su Android.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -59,7 +61,7 @@ Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS.
|
|||||||
| Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** |
|
| Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** |
|
||||||
| Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** |
|
| Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** |
|
||||||
| Logout UI | Icona | Icona SF Symbol | **Fatto** |
|
| Logout UI | Icona | Icona SF Symbol | **Fatto** |
|
||||||
| Versione | `2.0.5-native` / **26** | `2.0.5` / **26** | **Fatto** |
|
| Versione | `2.0.10-native` / **31** | `2.0.10` / **31** | **Fatto** |
|
||||||
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK |
|
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK |
|
||||||
|
|
||||||
### Residui fuori scope Android (opzionali)
|
### Residui fuori scope Android (opzionali)
|
||||||
@@ -141,7 +143,7 @@ xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
|||||||
|
|
||||||
Nessuna modifica server richiesta.
|
Nessuna modifica server richiesta.
|
||||||
|
|
||||||
Versione store target: **`2.0.5` / build `26`** (parità con Android `2.0.5-native` / versionCode `26`).
|
Versione store target: **`2.0.10` / build `31`** (parità con Android `2.0.10-native` / versionCode `31`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# iOS — allineamento password policy + errori API localizzati
|
||||||
|
|
||||||
|
Documento operativo per **Cursor su Mac**: allineare l’app iOS a backend/Android dopo il ramo `feature/password-policy` (merge su `main`).
|
||||||
|
|
||||||
|
**Aggiornato:** 2026-08-08
|
||||||
|
**Android di riferimento:** `2.0.10-native` (`versionCode` **31**)
|
||||||
|
**iOS (allineato):** marketing `2.0.10` / build `31`
|
||||||
|
**API produzione:** `https://www.matchlivetv.it`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contesto (già in produzione backend dopo questo rilascio)
|
||||||
|
|
||||||
|
### Policy password (server)
|
||||||
|
|
||||||
|
Definita in `backend/app/models/concerns/password_complexity.rb`:
|
||||||
|
|
||||||
|
- minimo **8** caratteri, massimo **72** byte (bcrypt)
|
||||||
|
- almeno **3 classi su 4**: minuscole, maiuscole, numeri, simboli
|
||||||
|
- in **cambio password** e **reset**: la nuova password **non** può coincidere con quella attuale
|
||||||
|
|
||||||
|
Validazione ActiveModel su `User` / `AdminAccount`. Endpoint che la applicano:
|
||||||
|
|
||||||
|
| Endpoint | Note |
|
||||||
|
|----------|------|
|
||||||
|
| `PATCH /api/v1/account/password` | App native + stessi codici errore |
|
||||||
|
| `PATCH /account/password` (web) | Flash I18n |
|
||||||
|
| reset password pubblico | Stesse regole |
|
||||||
|
| registrazione (`auth/register`) | Validazione modello |
|
||||||
|
|
||||||
|
### Errori API localizzati
|
||||||
|
|
||||||
|
`ApplicationController#set_api_locale` legge, in ordine:
|
||||||
|
|
||||||
|
1. header `X-Locale`
|
||||||
|
2. `Accept-Language`
|
||||||
|
3. `I18n.default_locale` (fallback; **retrocompatibile** se l’app vecchia non manda nulla)
|
||||||
|
|
||||||
|
Risposta invariata: `{ "error": "<messaggio già tradotto>" }` (o `{ "message": "..." }` su alcuni successi).
|
||||||
|
|
||||||
|
Chiavi rilevanti (`backend/config/locales/app.{it,en,fr,de,es}.yml`):
|
||||||
|
|
||||||
|
- `flash.accounts.password_too_short`
|
||||||
|
- `flash.accounts.password_too_long`
|
||||||
|
- `flash.accounts.password_too_weak`
|
||||||
|
- `flash.accounts.password_same_as_current`
|
||||||
|
- `flash.accounts.password_current_incorrect`
|
||||||
|
- `flash.accounts.password_mismatch`
|
||||||
|
- `flash.accounts.password_updated` / `name_required`
|
||||||
|
- analoghi in `flash.password_resets.*` e `flash.sessions.*`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stato iOS vs Android
|
||||||
|
|
||||||
|
| Area | Android 2.0.10 | iOS | Stato |
|
||||||
|
|------|----------------|-----|-------|
|
||||||
|
| Header `Accept-Language` su tutte le request API | OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **OK** |
|
||||||
|
| Hint UI nuova password | `account_new_password` | `account.new.password` | **OK** |
|
||||||
|
| Schermata Account | AccountScreen | AccountScreen | **OK** |
|
||||||
|
| Forgot password | ForgotPasswordScreen | ForgotPasswordScreen | **OK** |
|
||||||
|
| Parsing errori API | body `error` | `APIError.friendlyHttpMessage` | **OK** |
|
||||||
|
| Client-side pre-check complessità | No | No | N/A (come Android) |
|
||||||
|
| Versione store | `2.0.10-native` / **31** | `2.0.10` / **31** | **OK** |
|
||||||
|
| Signup in-app | Non principale | Nessuna UI register | N/A (signup web) |
|
||||||
|
|
||||||
|
### File iOS
|
||||||
|
|
||||||
|
```
|
||||||
|
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
|
||||||
|
native/ios/MatchLiveTv/Core/AppLanguage.swift
|
||||||
|
native/ios/MatchLiveTv/UI/Account/AccountScreen.swift
|
||||||
|
native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift
|
||||||
|
native/ios/MatchLiveTv/Resources/Info.plist
|
||||||
|
native/ios/generate_xcodeproj.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterio “fatto”
|
||||||
|
|
||||||
|
- [x] `Accept-Language` su login, me, account, change password, forgot (`MatchLiveAPI`)
|
||||||
|
- [x] Hint campo nuova password parla di “min. 8 / 3 tipi”
|
||||||
|
- [x] Catalogo stringhe account/forgot in it/en/fr/de/es
|
||||||
|
- [x] Versione bumpata a `2.0.10` / `31`
|
||||||
|
- [ ] QA manuale errori password in lingua UI (weak / same / mismatch / success)
|
||||||
|
|
||||||
|
Quando rilasci su TestFlight/App Store, conferma la build `31`.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# iOS — mute audio in diretta (allineamento da Mac)
|
||||||
|
|
||||||
|
Documento operativo per **Cursor su Mac**: verificare e, se serve, ritoccare l’app iOS dopo il ramo `feature/stream-audio-mute`.
|
||||||
|
|
||||||
|
**Aggiornato:** 2026-08-12
|
||||||
|
**Branch:** `feature/stream-audio-mute` (parte da `main`)
|
||||||
|
**Android di riferimento:** mute microfono in overlay + sync regia
|
||||||
|
**API produzione:** `https://www.matchlivetv.it`
|
||||||
|
|
||||||
|
Il codice iOS è **già nel branch** (engine, overlay, API, Cable, L10n). Su Mac va **compilato, testato su device** e, se HaishinKit 2.2.5 rifiuta un parametro, adattato come sotto.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Perché esiste
|
||||||
|
|
||||||
|
In palestra la musica di sottofondo finisce nel microfono del telefono. Il relay YouTube fa `-c:a copy`, quindi YouTube sente quella musica e può reclamare il copyright.
|
||||||
|
|
||||||
|
Il mute **non ferma** lo stream: continua video + overlay + AAC, ma i sample audio sono silenzio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contratto API / Cable (già in backend)
|
||||||
|
|
||||||
|
| Pezzo | Dettaglio |
|
||||||
|
|-------|-----------|
|
||||||
|
| Campo sessione | `audio_muted` (boolean, default `false`) in JSON sessione e `GET/POST /regia/:token/status.json` |
|
||||||
|
| REST app | `PATCH /api/v1/sessions/:id/audio_mute` body `{ "muted": true \| false }` |
|
||||||
|
| REST regia | `POST /regia/:token/audio_mute` body `{ "muted": true \| false }` (senza body: toggle) |
|
||||||
|
| Cable command | `{ "type": "command", "action": "mute_audio" \| "unmute_audio", "muted": true \| false }` |
|
||||||
|
| Cable event | `{ "type": "stream_event", "event": "audio_muted", "muted": true \| false }` |
|
||||||
|
|
||||||
|
Idempotente: se lo stato è già quello richiesto, il backend non reinvia eventi.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File iOS già modificati
|
||||||
|
|
||||||
|
```
|
||||||
|
native/ios/MatchLiveTv/Streaming/BroadcastModels.swift # BroadcastMetrics.audioMuted
|
||||||
|
native/ios/MatchLiveTv/Streaming/LiveBroadcastEngine.swift # setAudioMuted + AudioMixerSettings.isMuted
|
||||||
|
native/ios/MatchLiveTv/Streaming/LiveBroadcastCoordinator.swift # setAudioMuted
|
||||||
|
native/ios/MatchLiveTv/Domain/Models.swift # StreamSession.audioMuted + withAudioMuted
|
||||||
|
native/ios/MatchLiveTv/Data/API/ApiDtos.swift # audioMuted + AudioMuteRequest
|
||||||
|
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # setAudioMute
|
||||||
|
native/ios/MatchLiveTv/Data/Repository/SessionRepository.swift
|
||||||
|
native/ios/MatchLiveTv/Data/Cable/SessionCableService.swift # onAudioMute + AudioMuteCommandLogic
|
||||||
|
native/ios/MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift
|
||||||
|
native/ios/MatchLiveTv/UI/Broadcast/BroadcastScreen.swift
|
||||||
|
native/ios/MatchLiveTv/Core/AppLanguage.swift # IT/EN/FR/DE/ES
|
||||||
|
native/ios/MatchLiveTvTests/LiveBroadcastCoordinatorTests.swift
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Engine HaishinKit 2.2.5 (punto da verificare su Mac)
|
||||||
|
|
||||||
|
Implementazione attesa in `LiveBroadcastEngine`:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
func setAudioMuted(_ muted: Bool) async {
|
||||||
|
audioMuted = muted
|
||||||
|
await applyAudioMute()
|
||||||
|
metrics.audioMuted = muted
|
||||||
|
emitMetrics()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyAudioMute() async {
|
||||||
|
guard pipelineConfigured else { return }
|
||||||
|
var settings = await mixer.audioMixerSettings
|
||||||
|
settings.isMuted = audioMuted
|
||||||
|
var track = settings.tracks[0] ?? AudioMixerTrackSettings(downmix: true, channelMap: [0])
|
||||||
|
track.isMuted = audioMuted
|
||||||
|
settings.tracks[0] = track
|
||||||
|
try? await mixer.setAudioMixerSettings(settings)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `configurePipeline` i settings iniziali passano `isMuted: audioMuted` sia sul mixer sia sulla track 0.
|
||||||
|
|
||||||
|
**Se il build fallisce:**
|
||||||
|
|
||||||
|
1. `AudioMixerSettings` potrebbe non avere `isMuted` nel memberwise usato oggi — in quel caso muta solo `settings.tracks[0].isMuted`.
|
||||||
|
2. `setAudioMixerSettings` è `async throws` nel codice attuale; se la signature è sync, togli `try await`.
|
||||||
|
3. Non staccare il microfono (`attachAudio(nil)`): YouTube/MediaMTX vogliono AAC continuo. Mute = silenzio, non assenza di traccia.
|
||||||
|
|
||||||
|
Dopo `prepareBroadcast` / `resumeBroadcast` la schermata richiama `setAudioMuted(session.audioMuted)` per riallineare lo stato persistito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI overlay
|
||||||
|
|
||||||
|
Toolbar destra (come Android), sotto pausa:
|
||||||
|
|
||||||
|
| Stato | SF Symbol | Chiave L10n |
|
||||||
|
|-------|-----------|-------------|
|
||||||
|
| Audio on | `speaker.wave.2.fill` | `broadcast.mute.cd` |
|
||||||
|
| Audio off | `speaker.slash.fill` | `broadcast.unmute.cd` |
|
||||||
|
|
||||||
|
Bottone `highlighted` quando mutato (stesso verde della pausa). Pannello telemetria: riga `broadcast.audio.muted.label` se mutato.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chiavi L10n (già aggiunte, 5 lingue)
|
||||||
|
|
||||||
|
| Android | iOS |
|
||||||
|
|---------|-----|
|
||||||
|
| `broadcast_mute_cd` | `broadcast.mute.cd` |
|
||||||
|
| `broadcast_unmute_cd` | `broadcast.unmute.cd` |
|
||||||
|
| `broadcast_audio_muted_label` | `broadcast.audio.muted.label` |
|
||||||
|
| `broadcast_snackbar_muted` | `broadcast.snackbar.muted` |
|
||||||
|
| `broadcast_snackbar_unmuted` | `broadcast.snackbar.unmuted` |
|
||||||
|
| `broadcast_snackbar_muted_remote` | `broadcast.snackbar.muted.remote` |
|
||||||
|
| `broadcast_snackbar_unmuted_remote` | `broadcast.snackbar.unmuted.remote` |
|
||||||
|
| `broadcast_snackbar_mute_error` | `broadcast.snackbar.mute.error` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test su Mac (acceptance)
|
||||||
|
|
||||||
|
1. `cd native/ios && python3 generate_xcodeproj.py`
|
||||||
|
2. Build simulatore / device.
|
||||||
|
3. Avvia una diretta (anche verso ambiente di staging).
|
||||||
|
4. Overlay: tap mute → icona speaker.slash, snackbar «Audio silenziato», telemetria «Audio off».
|
||||||
|
5. Apri la **regia** sullo stesso match → il pulsante deve dire «Riattiva audio».
|
||||||
|
6. Dalla regia riattiva → il telefono mostra speaker.wave e snackbar «dalla regia».
|
||||||
|
7. Mute, metti in **pausa**, riprendi: l’audio resta mutato (stato persistito).
|
||||||
|
8. YouTube / player HLS: video continua, audio silenzioso (non assente).
|
||||||
|
9. Unit test: `LiveBroadcastCoordinatorTests.testAudioMuteCommandFromCable`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd native/ios
|
||||||
|
python3 generate_xcodeproj.py
|
||||||
|
xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
-derivedDataPath build/DerivedData \
|
||||||
|
ONLY_ACTIVE_ARCH=YES ARCHS=arm64 EXCLUDED_ARCHS=x86_64 \
|
||||||
|
build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Criterio “fatto”
|
||||||
|
|
||||||
|
- [ ] Build iOS senza errori HaishinKit
|
||||||
|
- [ ] Mute/unmute locale in overlay
|
||||||
|
- [ ] Sync bidirezionale con la regia via Cable + REST
|
||||||
|
- [ ] Mute sopravvive a pausa/ripresa
|
||||||
|
- [ ] AAC silenzioso in onda (niente drop della traccia audio)
|
||||||
|
- [ ] Stringhe IT/EN/FR/DE/ES visibili in overlay
|
||||||
+62
-34
@@ -1,23 +1,25 @@
|
|||||||
# Diretta live — architettura MediaMTX
|
# Diretta live — architettura MediaMTX
|
||||||
|
|
||||||
## Idea (non è folle)
|
**Ultimo aggiornamento:** 2026-08-12
|
||||||
|
|
||||||
|
## Idea
|
||||||
|
|
||||||
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
|
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
|
||||||
|
|
||||||
1. **Telefono in onda** → publisher RTMP (camera).
|
1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**).
|
||||||
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
||||||
3. **YouTube** → `ffmpeg` in container Rails/Sidekiq legge **sempre** quell’uscita (`-c copy`) e inoltra a `rtmp://a.rtmp.youtube.com/live2/...` per tutta la sessione.
|
3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, `-c:a copy`; l’AAC 48 kHz mono è già prodotto dall’app / slate).
|
||||||
|
|
||||||
Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server.
|
Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX.
|
||||||
|
|
||||||
## Componenti
|
## Componenti
|
||||||
|
|
||||||
| Pezzo | Ruolo |
|
| Pezzo | Ruolo |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| App Android | RTMP 48 kHz mono, 720p — solo quando in onda |
|
| App Android / iOS | RTMP 720p AAC mono; overlay tabellone/watermark in GPU sul flusso |
|
||||||
| MediaMTX | Path dinamico, `alwaysAvailable` + slate |
|
| MediaMTX | Path dinamico, `alwaysAvailable` + slate, HLS |
|
||||||
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (no hook wget su distroless) |
|
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (solo Sidekiq con `YOUTUBE_RELAY_WORKER=1`) |
|
||||||
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) |
|
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) + recording on/off |
|
||||||
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
|
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
|
||||||
|
|
||||||
## Pausa e continuità
|
## Pausa e continuità
|
||||||
@@ -27,36 +29,44 @@ Il telefono **non** invia la copertina: risparmia banda; lo switch è lato serve
|
|||||||
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
||||||
4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS.
|
4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS.
|
||||||
5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video.
|
5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video.
|
||||||
6. **YouTube** → relay ffmpeg continuo; vede la stessa uscita MediaMTX.
|
6. **YouTube** → se attivo, il relay ffmpeg continua sulla stessa uscita MediaMTX (vede slate in pausa).
|
||||||
|
|
||||||
Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader.
|
Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader.
|
||||||
|
|
||||||
**Recording**: disabilitato sul path live (`record: false`) — il recorder MediaMTX al switch slate→camera distruggeva il muxer HLS (`too many reordered frames`). I replay vanno gestiti con job dedicato (vedi `REPLAY_MODULE.md`).
|
**Recording**: sul path live parte `record: false`; viene acceso via API solo con publisher online e entitlement (`PublisherSync`). I replay finiscono in Garage con job dedicato (vedi `REPLAY_MODULE.md`).
|
||||||
|
|
||||||
Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`).
|
Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`).
|
||||||
|
|
||||||
## Infrastruttura vs browser
|
## Infrastruttura vs browser
|
||||||
|
|
||||||
```
|
```
|
||||||
Telefono RTMP ──► MediaMTX path live/match_{uuid}
|
Telefono RTMP (camera + overlay app)
|
||||||
│
|
│
|
||||||
publisher ON ├─► camera (H.264/AAC)
|
▼
|
||||||
publisher OFF└─► alwaysAvailable → /slates/offline.mp4
|
MediaMTX path live/match_{uuid}
|
||||||
│
|
│
|
||||||
├─► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX (prod) o proxy Rails (dev) ──► player web (HLS.js)
|
publisher ON → camera H.264/AAC (già con tabellone se overlay ≠ none)
|
||||||
└─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► YouTube
|
publisher OFF → alwaysAvailable → /slates/offline.mp4
|
||||||
|
│
|
||||||
|
├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js)
|
||||||
|
└──► (solo platform=youtube)
|
||||||
|
Streams::YoutubeRelay
|
||||||
|
ffmpeg: -c:v copy, -c:a copy ──► RTMPS YouTube
|
||||||
```
|
```
|
||||||
|
|
||||||
| Responsabilità | Dove |
|
| Responsabilità | Dove |
|
||||||
|----------------|------|
|
|----------------|------|
|
||||||
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
||||||
| Continuità YouTube | **Relay ffmpeg** sulla stessa uscita |
|
| Tabellone / watermark sullo stream | **App nativa** (GPU → RTMP) |
|
||||||
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) |
|
| Continuità YouTube | **`Streams::YoutubeRelay`** (ffmpeg in Sidekiq) |
|
||||||
|
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) + UI web |
|
||||||
| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) |
|
| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) |
|
||||||
| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) |
|
| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) |
|
||||||
|
|
||||||
Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato.
|
Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato.
|
||||||
|
|
||||||
|
URL HLS pubblico: `https://www.matchlivetv.it/hls/live/match_{uuid}/index.m3u8` (`StreamSession#effective_hls_path_name` = path camera, **non** più `*_air`).
|
||||||
|
|
||||||
### Player web — anti-scatti (HLS.js)
|
### Player web — anti-scatti (HLS.js)
|
||||||
|
|
||||||
Configurazione in `backend/app/views/public/live/show.html.erb`:
|
Configurazione in `backend/app/views/public/live/show.html.erb`:
|
||||||
@@ -67,30 +77,48 @@ Configurazione in `backend/app/views/public/live/show.html.erb`:
|
|||||||
- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`.
|
- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`.
|
||||||
- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`).
|
- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`).
|
||||||
|
|
||||||
### Grafica nel video (overlay server)
|
### Grafica nel video (overlay)
|
||||||
|
|
||||||
Tabellone, badge stato («In onda», «In pausa», «Copertina», …) e banner **Match Live TV** sono **bruciati nel flusso** da `Streams::OverlayRelay`:
|
**Non** c’è più un relay server `Streams::OverlayRelay` che ricodifica con PNG.
|
||||||
|
|
||||||
```
|
Il tabellone è composito **sul telefono** prima dell’RTMP (vedi [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md)):
|
||||||
Telefono → live/match_{uuid} (grezzo)
|
|
||||||
└─► ffmpeg + PNG 1280×720 (Streams::Overlay::Refresh ogni ~2s)
|
|
||||||
└─► live/match_{uuid}_air
|
|
||||||
├─► HLS (sito, anche fullscreen)
|
|
||||||
└─► YouTube relay (-c copy)
|
|
||||||
```
|
|
||||||
|
|
||||||
- `Streams::Overlay::SvgBuilder` + `rsvg-convert` generano `tmp/stream_overlays/{session_id}/overlay.png`
|
1. `OverlayState` da `effectiveOverlayKind` + `ScoreState`
|
||||||
- `Streams::Overlay::Refresh` + `bin/overlay_png_feeder.rb` aggiornano la PNG ogni ~2s (e su punteggio/stato); il feeder la invia a ffmpeg via FIFO perché `-loop 1` non rilegge il file su disco
|
2. `OverlayRenderer` / canvas → bitmap
|
||||||
- Volume Docker condiviso `stream_overlays` tra **rails** (ffmpeg) e **sidekiq** (OverlayRefreshJob); altrimenti il job scrive PNG su un filesystem diverso e badge/punteggio restano congelati
|
3. Filtro GPU sul encoder RTMP
|
||||||
- La pagina live **non** sovrappone più HTML sul player (evita incongruenze con fullscreen / YouTube)
|
|
||||||
- **Qualità video**: il relay normalizza a **30 fps CFR** (l’app RTMP può inviare timestamp errati) e ricodifica a **≥4.5 Mbps** (`fast`, no B-frame). Override: `OVERLAY_RELAY_VIDEO_KBPS`, `OVERLAY_RELAY_X264_PRESET`, `OVERLAY_RELAY_FPS`.
|
La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non sostituisce il tabellone nel video.
|
||||||
|
|
||||||
|
### Ruolo di ffmpeg oggi
|
||||||
|
|
||||||
|
| Quando | Cosa | Peso CPU |
|
||||||
|
|--------|------|----------|
|
||||||
|
| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video/audio** → RTMPS (remux) | Basso **per diretta**, scala con N processi |
|
||||||
|
| Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero |
|
||||||
|
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
|
||||||
|
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
|
||||||
|
|
||||||
|
Non esiste più la ricodifica H.264 server-side dell’overlay (era il carico principale).
|
||||||
|
|
||||||
### Punteggio (persistenza)
|
### Punteggio (persistenza)
|
||||||
|
|
||||||
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay.
|
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `score_action`) → `Scoring::SyncState`, poi aggiorna l’overlay locale in camera.
|
||||||
|
|
||||||
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
||||||
|
|
||||||
|
### Mute audio (copyright palestra / YouTube)
|
||||||
|
|
||||||
|
Se in palestra c’è musica di sottofondo, YouTube può reclamare il copyright sul flusso inoltrato (`-c:a copy`). L’operatore può **silenziare il microfono** senza interrompere video, overlay, HLS o relay:
|
||||||
|
|
||||||
|
1. Lo stato `audio_muted` è persistito su `stream_sessions` (default `false`).
|
||||||
|
2. API `PATCH /api/v1/sessions/:id/audio_mute` `{ "muted": true|false }` e `POST /regia/:token/audio_mute`.
|
||||||
|
3. Action Cable: `{ type: "command", action: "mute_audio"|"unmute_audio" }` e `{ type: "stream_event", event: "audio_muted", muted: true|false }`.
|
||||||
|
4. L’app **non toglie la traccia AAC**: azzera i sample PCM (silenzio). YouTube e MediaMTX continuano a ricevere audio 48 kHz mono.
|
||||||
|
|
||||||
|
Pulsante mute: overlay diretta (Android/iOS, accanto a pausa) e pagina regia.
|
||||||
|
|
||||||
|
Dettaglio implementazione iOS: [`IOS_STREAM_AUDIO_MUTE.md`](IOS_STREAM_AUDIO_MUTE.md).
|
||||||
|
|
||||||
## Rigenerare la slate
|
## Rigenerare la slate
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
|
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
|
||||||
|
|
||||||
Esigenze custom: contatto `info@matchlive.it`.
|
Esigenze custom: contatto `info@matchlivetv.it`.
|
||||||
|
|
||||||
## Sito
|
## Sito
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
|
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
|
||||||
|
|
||||||
|
**Importante (2026-07):** l’overlay video è composito **solo sull’app nativa** (Android/iOS) e viaggia già nel RTMP verso MediaMTX. Non esiste più un relay server (`Streams::OverlayRelay`) che ricodifica con PNG. Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Panoramica
|
## Panoramica
|
||||||
@@ -162,12 +164,14 @@ Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellon
|
|||||||
|
|
||||||
### Layout app mobile (Android e iOS)
|
### Layout app mobile (Android e iOS)
|
||||||
|
|
||||||
Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva:
|
Sull’app nativa l’overlay è composito in GPU **prima** dell’encode RTMP (nessuna ricodifica server aggiuntiva):
|
||||||
|
|
||||||
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
||||||
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
||||||
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
|
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
|
||||||
|
|
||||||
|
MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio).
|
||||||
|
|
||||||
Elementi grafici (`OverlayCanvasRenderer`):
|
Elementi grafici (`OverlayCanvasRenderer`):
|
||||||
|
|
||||||
| Elemento | Quando è attivo |
|
| Elemento | Quando è attivo |
|
||||||
|
|||||||
@@ -1,56 +1,32 @@
|
|||||||
# YouTube Live — verificato ✅ (5 giu 2026)
|
# YouTube Live — checklist operativa
|
||||||
|
|
||||||
## Test completato automaticamente
|
**Aggiornato:** 2026-07-29
|
||||||
|
Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server.
|
||||||
|
|
||||||
Pipeline end-to-end **funzionante** sul server di produzione:
|
## Verifica rapida su server
|
||||||
|
|
||||||
| Step | Esito |
|
|
||||||
|------|--------|
|
|
||||||
| Setup broadcast + stream_key | ✅ |
|
|
||||||
| Overlay ffmpeg con tee RTMPS YouTube | ✅ |
|
|
||||||
| Copertina / tabellone su YouTube | ✅ |
|
|
||||||
| RTMP simulato (test pattern) | ✅ |
|
|
||||||
| Broadcast `lifecycle=live` | ✅ |
|
|
||||||
|
|
||||||
**Prova visiva:** https://www.youtube.com/watch?v=0WmCsW_Luik
|
|
||||||
(Titolo: *Tigers Volley vs Avversario prova* — sessione di test, ora terminata)
|
|
||||||
|
|
||||||
## APK da installare sul telefono
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Sul PC (già compilato):
|
|
||||||
mobile/build/app/outputs/flutter-apk/app-release.apk
|
|
||||||
# Versione: 1.2.10+13 — API https://www.matchlivetv.it
|
|
||||||
```
|
|
||||||
|
|
||||||
Installa sul Redmi (sostituisce versioni precedenti).
|
|
||||||
|
|
||||||
## Avvio diretta dall’app (2 minuti)
|
|
||||||
|
|
||||||
1. Partita → **YouTube Live** → wizard → **Camera**
|
|
||||||
2. Attendi sul passo rete che compaia il link YouTube (poll automatico, fino a 3 min)
|
|
||||||
3. Avvia: entro ~90s su YouTube compare **copertina**, poi video con tabellone quando il telefono pubblica RTMP
|
|
||||||
|
|
||||||
## Test server senza telefono
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh eminux@192.168.1.146
|
ssh eminux@192.168.1.146
|
||||||
docker exec infra-rails-1 bin/rails youtube:e2e
|
docker exec infra-rails-1 bin/rails youtube:e2e # se il task è ancora presente
|
||||||
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
|
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
|
||||||
|
docker logs infra-sidekiq-1 2>&1 | grep -E 'YoutubeRelay|YoutubeBroadcastActivate|LivePipeline' | tail -40
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Avvio diretta dall’app
|
||||||
|
|
||||||
|
1. Partita → **YouTube Live** → wizard → **Camera**
|
||||||
|
2. Attendi sul passo rete che compaia il link YouTube (poll automatico)
|
||||||
|
3. Avvia: entro ~90s su YouTube compare video (slate MediaMTX se il telefono non pubblica ancora; tabellone quando l’app pubblica RTMP con overlay)
|
||||||
|
|
||||||
## Se qualcosa non va
|
## Se qualcosa non va
|
||||||
|
|
||||||
| Sintomo | Cosa fare |
|
| Sintomo | Cosa fare |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| «YouTube temporaneamente occupato» | Attendi 15–20 min (rate limit Google). **Non** creare molte sessioni di fila. |
|
| «YouTube temporaneamente occupato» | Attendi 15–20 min (rate limit Google). **Non** creare molte sessioni di fila. |
|
||||||
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep OverlayRelay` — manca `stream_key` o tee RTMPS |
|
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep YoutubeRelay` — manca `stream_key` o relay non partito |
|
||||||
| App errore avvio diretta | APK 1.2.10+13; controlla rete verso matchlivetv.it |
|
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: permessi camera / messaggio «Connessione RTMP»; relay YouTube non parte finché MediaMTX non vede il publisher |
|
||||||
|
| YouTube resta in waiting | Log relay in sidekiq; `StreamPublisherSyncJob` + activate retry |
|
||||||
|
|
||||||
## Fix deployati (server)
|
## Note storiche
|
||||||
|
|
||||||
- Retry setup broadcast su rate limit (fino a 15 tentativi)
|
Checklist precedente (giu 2026) citava `OverlayRelay` / tee RTMPS dall’overlay server: **deprecato**. Non cercare più log `OverlayRelay` o `overlay_relay_*.log`.
|
||||||
- Un solo job setup per sessione (lock Redis)
|
|
||||||
- Throttle API YouTube globale
|
|
||||||
- Riavvio overlay quando arriva `stream_key` dopo start senza tee
|
|
||||||
- Activate solo con overlay attivo + broadcast pronta
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Match Live TV supporta due modalità YouTube, legate al piano società:
|
|||||||
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
|
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
|
||||||
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
|
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
|
||||||
|
|
||||||
Flusso tecnico: telefono → RTMP MediaMTX → (relay ffmpeg) → YouTube Live.
|
Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy -c:a copy`) → YouTube Live.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -85,5 +85,5 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
|||||||
## 6. Produzione
|
## 6. Produzione
|
||||||
|
|
||||||
- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
|
- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
|
||||||
- Il relay ffmpeg gira nel container MediaMTX (`runOnReady`).
|
- Il relay ffmpeg (`Streams::YoutubeRelay`) gira nel container **sidekiq** (`YOUTUBE_RELAY_WORKER=1`), non su MediaMTX `runOnReady`.
|
||||||
- Apri RTMP **1935** sul router verso il server.
|
- Apri RTMP **1935** sul router verso il server.
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
# Piano: overflow relay YouTube su Hetzner Cloud
|
||||||
|
|
||||||
|
**Stato:** piano / design — non implementato
|
||||||
|
**Ultimo aggiornamento:** 2026-07-29
|
||||||
|
**Contesto:** produzione attuale su host dedicato (es. Proxmox/Hetzner) con MediaMTX + Sidekiq sullo stesso box; obiettivo >10 live YouTube contemporanee senza abbandonare il middle-tier.
|
||||||
|
|
||||||
|
Documenti correlati: [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Scenario
|
||||||
|
|
||||||
|
### Problema di prodotto
|
||||||
|
|
||||||
|
Match Live TV disaccoppia il telefono da YouTube:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Telefono (rete mobile instabile)
|
||||||
|
│ RTMP
|
||||||
|
▼
|
||||||
|
MediaMTX (sempre attivo, slate / alwaysAvailable)
|
||||||
|
│
|
||||||
|
├── HLS → sito
|
||||||
|
└── YoutubeRelay (ffmpeg) → RTMPS YouTube
|
||||||
|
```
|
||||||
|
|
||||||
|
Se cade solo il tratto telefono→MediaMTX, YouTube resta in onda (slate).
|
||||||
|
Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la live pubblica cadrebbe con la rete mobile. Inoltre YouTube non è pensato come “Go Live dall’app mobile” senza vincoli di canale: noi usiamo API + RTMPS dal server.
|
||||||
|
|
||||||
|
### Problema di capacità
|
||||||
|
|
||||||
|
Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video+audio **copy** / remux).
|
||||||
|
Il carico scala con **N processi**, non con gli spettatori HLS.
|
||||||
|
|
||||||
|
Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host):
|
||||||
|
|
||||||
|
| Live YT contemporanee | Situazione attesa |
|
||||||
|
|-----------------------|-------------------|
|
||||||
|
| 1–4 | Comodo |
|
||||||
|
| 5–8 | Teso (CPU/RAM, latenza job Sidekiq) |
|
||||||
|
| ≥10 | Rischioso (relay instabili, watchdog, slate prolungate su YT) |
|
||||||
|
|
||||||
|
**Obiettivo:** mantenere MediaMTX (e Rails) sul dedicated; quando N supera la soglia comoda, **accendere capacità Hetzner Cloud solo per i relay**, per il tempo del picco, poi spegnerla.
|
||||||
|
|
||||||
|
### Cosa NON è questo piano
|
||||||
|
|
||||||
|
- Non sostituisce MediaMTX con Mux/AWS MediaLive.
|
||||||
|
- Non sposta l’ingest RTMP del telefono sul cloud (cold start e sticky URL sono incompatibili con “zero nodi a riposo”).
|
||||||
|
- Non è autoscaling del monolite Docker Compose intero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Principio di progettazione
|
||||||
|
|
||||||
|
| Componente | Dove resta | Perché |
|
||||||
|
|------------|------------|--------|
|
||||||
|
| MediaMTX (RTMP ingest + slate + HLS) | **Dedicated sempre acceso** | URL stabile per l’app; continuità YouTube via slate |
|
||||||
|
| Rails / Postgres / Redis / Garage / edge | **Dedicated** (o gestiti fissi) | Stato, auth, replay; non sono il collo di bottiglia video live |
|
||||||
|
| `YoutubeRelay` (ffmpeg) | **Dedicated fino a soglia + overflow Cloud** | Unico pezzo che moltiplica CPU con N live YT |
|
||||||
|
| Job Sidekiq non-video (mail, ops, post-process) | Dedicated (coda separata) | Non devono competere con ffmpeg sui worker overflow |
|
||||||
|
|
||||||
|
Formula mentale:
|
||||||
|
|
||||||
|
```text
|
||||||
|
capacità_yt ≈ core_dedicati_relay + Σ core_vm_overflow_calde
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Architettura target (overflow)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[ telefoni RTMP ]
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────┐
|
||||||
|
│ Dedicated (sempre on) │
|
||||||
|
│ MediaMTX · Rails · DB │
|
||||||
|
│ Redis · Garage · edge │
|
||||||
|
│ Sidekiq "core" │
|
||||||
|
│ (+ pochi relay locali) │
|
||||||
|
└───────────┬─────────────┘
|
||||||
|
│
|
||||||
|
intake RTMP/HLS │ (rete privata / tunnel / IP allowlist)
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
[relay-local] [overflow-1] [overflow-N]
|
||||||
|
Sidekiq+ffmpeg Hetzner Cloud Hetzner Cloud
|
||||||
|
YOUTUBE_RELAY=1 a ore a ore
|
||||||
|
│ │ │
|
||||||
|
└───────────────┴───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
YouTube RTMPS
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ruolo delle VM overflow
|
||||||
|
|
||||||
|
Immagine minimale (o Compose snello):
|
||||||
|
|
||||||
|
- Sidekiq con `YOUTUBE_RELAY_WORKER=1`
|
||||||
|
- `ffmpeg` disponibile
|
||||||
|
- Connessione a **Redis** del dedicated (coda + lock `youtube_relay:owner:*`)
|
||||||
|
- Lettura intake da MediaMTX (RTMP preferito, HLS fallback come oggi)
|
||||||
|
- **Niente** Postgres scrittura diretta obbligatoria se i job relay usano già Redis + API; in pratica oggi Rails/Sidekiq legge Postgres → le VM overflow devono raggiungere anche DB **oppure** si isola una coda “relay-only” con worker che riceve payload già risolto (vedi §5.2)
|
||||||
|
|
||||||
|
Stato attuale del codice: `YoutubeRelay` gira in processo Sidekiq Rails completo (accesso a `StreamSession`, MediaMTX client, Redis PID/owner). Le VM overflow sono quindi **worker Sidekiq Rails**, non demoni ffmpeg nudi — almeno nella fase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Colli di bottiglia (cosa può rompersi comunque)
|
||||||
|
|
||||||
|
Anche con overflow infinito di CPU, restano limiti. Ordine di probabilità/impatto:
|
||||||
|
|
||||||
|
### 4.1 MediaMTX sul dedicated (alto)
|
||||||
|
|
||||||
|
- N publisher RTMP + N reader (HLS siti + N ffmpeg che rileggono lo stesso path).
|
||||||
|
- Ogni relay in più è un **reader** su MediaMTX (RTMP pull o HLS).
|
||||||
|
- Sintomi: path `ready` flaky, HLS a scatti sul sito, relay che rientrano in fallback HLS, CPU MediaMTX alta anche con relay scarichi.
|
||||||
|
|
||||||
|
**Mitigazione futura (fuori da questo overflow):** secondo nodo MediaMTX o sharding path; per ora monitorare `readers`, CPU `mediamtx`, bitrate aggregato.
|
||||||
|
|
||||||
|
### 4.2 Uplink Internet del dedicated (alto)
|
||||||
|
|
||||||
|
- Telefoni → dedicated (ingresso RTMP).
|
||||||
|
- Dedicated → YouTube **se** i relay restano locali.
|
||||||
|
- Con overflow Cloud: il dedicated manda **copia dello stream** verso le VM (pull dalle VM = traffico **uscita** dal dedicated verso Hetzner Cloud), poi le VM mandano a YouTube.
|
||||||
|
|
||||||
|
Attenzione al verso:
|
||||||
|
|
||||||
|
| Dove gira il relay | Traffico tipico |
|
||||||
|
|--------------------|-----------------|
|
||||||
|
| Sul dedicated | In: telefoni. Out: N× verso YouTube |
|
||||||
|
| Su Hetzner Cloud | Out dedicated → Cloud (intake). Out Cloud → YouTube |
|
||||||
|
|
||||||
|
Se l’uplink casa/datacenter verso Internet è stretto, spostare i relay sul Cloud **sposta** il carico out YouTube fuori dal dedicated, ma **aggiunge** out dedicated→Cloud (stesso ordine di grandezza del video).
|
||||||
|
Vantaggio reale solo se:
|
||||||
|
|
||||||
|
- l’uplink del dedicated satura soprattutto per **CPU** (oggi sì), oppure
|
||||||
|
- dedicated e Cloud sono in **stessa regione Hetzner con rete privata** (traffico interno economico/veloce) e l’uscita verso YouTube esce dalla rete Hetzner Cloud (migliore peering).
|
||||||
|
|
||||||
|
**Decisione di rete obbligatoria prima di implementare** (vedi §6).
|
||||||
|
|
||||||
|
### 4.3 Redis / lock owner (medio)
|
||||||
|
|
||||||
|
Oggi:
|
||||||
|
|
||||||
|
- `youtube_relay:pid:%s` — PID locale al worker
|
||||||
|
- `youtube_relay:owner:%s` — `HOSTNAME` del worker
|
||||||
|
- `running?` considera attivo un owner remoto se TTL > 30s
|
||||||
|
|
||||||
|
Con più worker:
|
||||||
|
|
||||||
|
- Due ensure concorrenti possono avviare due ffmpeg (debounce 5s aiuta poco cross-host).
|
||||||
|
- `stop` da Rails manda job: deve raggiungere il worker **owner**, non un overflow a caso.
|
||||||
|
- Kill del PID funziona solo sulla macchina owner (`/proc`).
|
||||||
|
|
||||||
|
Serve disciplina di coda / routing (§5.3).
|
||||||
|
|
||||||
|
### 4.4 Cold start VM (medio)
|
||||||
|
|
||||||
|
Creare una CPX/CCX Hetzner: ordine di **30–90+ secondi** (API + boot + pull image + Sidekiq ready).
|
||||||
|
Se la 15ª live parte e non c’è capacità, la broadcast YouTube resta “in attesa” finché non c’è worker.
|
||||||
|
|
||||||
|
**Requisito prodotto:** overflow **a caldo** = pool minimo già acceso nei weekend / fasce note, non “da zero al fischio”.
|
||||||
|
|
||||||
|
### 4.5 YouTube / account / quote (basso–medio, esterno)
|
||||||
|
|
||||||
|
- Limiti API, token OAuth, ban temporanei, ingest RTMPS rifiutato.
|
||||||
|
- Non risolvibili con più CPU; restano nel runbook ops esistente.
|
||||||
|
|
||||||
|
### 4.6 Post-process replay (basso in live, alto a fine giornata)
|
||||||
|
|
||||||
|
`UploadFromSession` / thumbnail competono su Sidekiq e disco.
|
||||||
|
I worker overflow **non** devono prendere coda `default` di merge se sono pensati solo per relay — altrimenti spegnendo le VM a fine picco uccidete job a metà.
|
||||||
|
|
||||||
|
### 4.7 Costo e idle (operativo)
|
||||||
|
|
||||||
|
VM dimenticate accese = bolletta. Serve TTL, scale-in aggressivo, alert “overflow acceso da >Xh con 0 relay”.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Complessità di implementazione
|
||||||
|
|
||||||
|
### 5.1 Rete: come le VM leggono MediaMTX
|
||||||
|
|
||||||
|
Opzioni (sceglierne **una** in fase 0):
|
||||||
|
|
||||||
|
| Opzione | Pro | Contro |
|
||||||
|
|---------|-----|--------|
|
||||||
|
| **A. Stessa location Hetzner + private network** (dedicated Robot + Cloud nella stessa rete) | Bassa latenza, traffico interno, modello pulito | Richiede che il dedicated sia (o diventi) in ecosistema Hetzner collegabile |
|
||||||
|
| **B. WireGuard/Tailscale** dedicated ↔ Cloud | Funziona anche da Proxmox casa | Ops tunnel, MTU, failover; latenza |
|
||||||
|
| **C. Esporre RTMP/HLS intake in lettura** (IP allowlist VM overflow) | Semplice | Superficie attacco; non esporre senza auth/TLS dove possibile |
|
||||||
|
| **D. Relay locale che pusha a Cloud** | Dedicated controlla egress | Doppia hop, più pezzi |
|
||||||
|
|
||||||
|
Raccomandazione di piano: **A se il dedicated è/andrà su Hetzner; altrimenti B**. Evitare C in chiaro su WAN.
|
||||||
|
|
||||||
|
Variabili da prevedere sulle VM:
|
||||||
|
|
||||||
|
- `MEDIAMTX_INTERNAL_RTMP_URL` → URL raggiungibile dalle VM (non `rtmp://mediamtx:1935` Docker-locale)
|
||||||
|
- `MEDIAMTX_HLS_URL` → idem
|
||||||
|
- MediaMTX API (`:9997`) raggiungibile in privato per `intake_available?` / PublisherOnline, **mai** su Internet aperto
|
||||||
|
|
||||||
|
### 5.2 Accesso Postgres e segreti
|
||||||
|
|
||||||
|
Worker Sidekiq Rails oggi necessitano:
|
||||||
|
|
||||||
|
- `DATABASE_URL` (o replica read + job che non scrivono — irrealistico all’inizio)
|
||||||
|
- `REDIS_URL`
|
||||||
|
- `SECRET_KEY_BASE` / credenziali YouTube via DB
|
||||||
|
- stessi env di produzione (ridotti)
|
||||||
|
|
||||||
|
Complessità: ogni overflow è un **nodo fidato** della rete app.
|
||||||
|
Immagine Docker identica a `sidekiq` prod, env da secret manager o file scp/cloud-init, **nessuna** porta Rails pubblica sulle VM.
|
||||||
|
|
||||||
|
### 5.3 Code Sidekiq e affinità relay
|
||||||
|
|
||||||
|
Stato oggi: ensure/stop via job + processo locale con owner in Redis.
|
||||||
|
|
||||||
|
Per multi-host serve esplicitare:
|
||||||
|
|
||||||
|
1. **Coda dedicata** `youtube_relay` (solo worker con `YOUTUBE_RELAY_WORKER=1`).
|
||||||
|
2. **Cap locale**: ogni worker ha `RELAY_MAX_CONCURRENT` (es. cores−1).
|
||||||
|
3. **Scheduling**:
|
||||||
|
- Fase 1 (manuale): overflow sempre in ascolto sulla coda; Sidekiq distribuisce i job; `ensure_on_worker!` no-op se a cap (re-enqueue o lascia ad altro worker).
|
||||||
|
- Fase 2: controller di capacità che alza/abbassa N VM in base a `relay_attivi` e profondità coda.
|
||||||
|
4. **Stop**: job `YoutubeRelayStopJob` deve eseguire **solo sull’owner** (Sidekiq unique + check `owner == HOSTNAME`, altrimenti requeue con delay breve).
|
||||||
|
|
||||||
|
Nota: i PID in Redis non sono globalmente killabili — il modello owner è già abbozzato; va reso robusto cross-host.
|
||||||
|
|
||||||
|
### 5.4 Scale-out / scale-in (lifecycle VM)
|
||||||
|
|
||||||
|
```text
|
||||||
|
Metriche → Decisione → hcloud CLI/API → cloud-init → Sidekiq ready → in coda
|
||||||
|
↘ fallisce → alert ops, non spegnere dedicated
|
||||||
|
```
|
||||||
|
|
||||||
|
**Scale-out trigger (esempi):**
|
||||||
|
|
||||||
|
- `youtube_relay` depth > 0 per >60s **oppure**
|
||||||
|
- `relay_attivi_local >= RELAY_SOFT_CAP` **e** nuove sessioni YT in `connecting|live`
|
||||||
|
|
||||||
|
**Scale-in trigger:**
|
||||||
|
|
||||||
|
- `relay_attivi` sulle VM overflow = 0 per >15–30 min **e**
|
||||||
|
- fuori dalla finestra “weekend caldo” (opzionale)
|
||||||
|
|
||||||
|
**Warm pool:** sabato 8:00 → min 1–2 VM già up; domenica 23:00 → min 0.
|
||||||
|
|
||||||
|
### 5.5 Idempotenza e split-brain
|
||||||
|
|
||||||
|
Scenario da evitare: dedicated e overflow avviano entrambi ffmpeg sulla stessa `stream_key` → YouTube flappa.
|
||||||
|
|
||||||
|
Mitigazioni:
|
||||||
|
|
||||||
|
- Lock Redis con fencing token / TTL heartbeat rinnovato dal processo owner ogni N secondi
|
||||||
|
- Watchdog (`YoutubeIngestWatchdogJob`) deve rispettare owner remoto (già parzialmente così via TTL)
|
||||||
|
- Un solo writer della broadcast activate
|
||||||
|
|
||||||
|
### 5.6 Observability
|
||||||
|
|
||||||
|
Senza metriche l’overflow è cieco. Minimo:
|
||||||
|
|
||||||
|
| Metrica | Dove |
|
||||||
|
|---------|------|
|
||||||
|
| N relay ffmpeg vivi (local / per host) | Redis + `pgrep` / heartbeat |
|
||||||
|
| CPU MediaMTX, CPU Sidekiq | node exporter / docker stats |
|
||||||
|
| Profondità coda `youtube_relay` | Sidekiq API |
|
||||||
|
| Bitrate / errori ffmpeg per session | log già in `log/youtube_relay_*.log` |
|
||||||
|
| VM overflow accese, € stimati | tag Hetzner + cron report |
|
||||||
|
| Latenza intake Cloud (RTT dedicated↔VM) | check periodico |
|
||||||
|
|
||||||
|
Alert ntfy esistenti: estendere con `relay_overflow_saturated`, `mediamtx_cpu_high`, `overflow_vm_orphan`.
|
||||||
|
|
||||||
|
### 5.7 Sicurezza
|
||||||
|
|
||||||
|
- Firewall: VM → solo Redis, Postgres, MediaMTX (porte interne), YouTube egress 443
|
||||||
|
- Nessun SSH password; chiave o console Hetzner
|
||||||
|
- Stream key YouTube solo in DB/encrypted; non in user-data in chiaro se evitabile
|
||||||
|
- Image aggiornata; spegnimento = destroy, non “pausare” dischi dimenticati per mesi
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Decisioni aperte (bloccare prima del codice)
|
||||||
|
|
||||||
|
| # | Domanda | Impatto |
|
||||||
|
|---|---------|---------|
|
||||||
|
| D1 | Dedicated resta su LAN casa/Proxmox o migra/affianca Hetzner Robot? | Sceglie rete A vs B (§5.1) |
|
||||||
|
| D2 | Soft cap relay sul dedicated (es. 4? 6?) | Quando scatta overflow |
|
||||||
|
| D3 | Warm pool fisso weekend vs solo reactive | Cold start vs costo |
|
||||||
|
| D4 | Tipo VM (CPX shared vs CCX dedicated CPU) | €/live e jitter AAC |
|
||||||
|
| D5 | Stessa immagine `sidekiq` vs worker slim | Tempo boot e superficie |
|
||||||
|
| D6 | Chi orchestra le VM? (script cron su dedicated, Terraform, small Go/Ruby service) | Ops ownership |
|
||||||
|
|
||||||
|
Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Dimensionamento indicativo
|
||||||
|
|
||||||
|
Ipotesi: 720p, remux copy A/V — **~0.15–0.4 vCPU** medi per relay (picchi su analyze/reconnect).
|
||||||
|
|
||||||
|
| Target live YT | Dedicated (relay) | Overflow tipico | Note |
|
||||||
|
|----------------|-------------------|-----------------|------|
|
||||||
|
| ≤6 | Tutto locale | 0 | Situazione attuale “comoda” se box dedicato ai relay |
|
||||||
|
| 10–15 | soft cap 4–6 | 1× VM 4–8 vCPU | Warm consigliato in giornata evento |
|
||||||
|
| 20–30 | soft cap 4–6 | 2–4× VM | MediaMTX e uplink diventano critici |
|
||||||
|
| 50+ | softire anche ingest | N VM + 2° MediaMTX | Fuori scope overflow-only |
|
||||||
|
|
||||||
|
Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg contro slate MediaMTX) prima di promettere SLA commerciali.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Piano a fasi (pronti a reagire, non a over-build)
|
||||||
|
|
||||||
|
### Fase 0 — Misura e soglie (1–2 giorni ops)
|
||||||
|
|
||||||
|
- [ ] Dashboard/manuale: N live YT, CPU `sidekiq`, CPU `mediamtx`, uplink
|
||||||
|
- [ ] Definire `RELAY_SOFT_CAP` empirico sul dedicated
|
||||||
|
- [ ] Chiudere **D1** (rete)
|
||||||
|
- [ ] Load test controllato: 8–12 relay solo slate (senza telefoni) per vedere dove rompe
|
||||||
|
|
||||||
|
**Criterio go/no-go overflow:** saturazione CPU Sidekiq/ffmpeg **prima** di MediaMTX/uplink. Se saturano prima rete o MediaMTX, overflow Cloud non basta.
|
||||||
|
|
||||||
|
### Fase 1 — Overflow manuale “a caldo” (MVP ops)
|
||||||
|
|
||||||
|
- [ ] Snapshot/immagine Sidekiq relay-ready su Hetzner Cloud
|
||||||
|
- [ ] cloud-init: env, WireGuard o private net, `docker compose up sidekiq`
|
||||||
|
- [ ] Runbook: “accendi 1 VM”, verifica `YoutubeRelay` su HOSTNAME nuovo, “spegni”
|
||||||
|
- [ ] Soft cap sul dedicated: nuovi ensure preferiscono coda se locali pieni (anche patch minima)
|
||||||
|
- [ ] Alert se VM overflow up da >3h con 0 owner keys
|
||||||
|
|
||||||
|
**Reazione a incident:** operatore umano accende/spegne; nessun autoscaler ancora.
|
||||||
|
|
||||||
|
### Fase 2 — Routing coda robusto
|
||||||
|
|
||||||
|
- [ ] Coda `youtube_relay` isolata
|
||||||
|
- [ ] Heartbeat owner Redis
|
||||||
|
- [ ] Stop/ensure sticky all’owner
|
||||||
|
- [ ] Cap per worker + requeue
|
||||||
|
|
||||||
|
### Fase 3 — Autoscaling controllato
|
||||||
|
|
||||||
|
- [ ] Job/cron: se saturazione → `hcloud server create` fino a `OVERFLOW_MAX`
|
||||||
|
- [ ] Scale-in solo se idle e fuori warm window
|
||||||
|
- [ ] Budget mensile + kill switch
|
||||||
|
|
||||||
|
### Fase 4 — Solo se necessario
|
||||||
|
|
||||||
|
- Secondo MediaMTX / sharding
|
||||||
|
- ~~Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
|
||||||
|
- Dedicated più grosso come baseline (spesso più economico dell’overflow cronico ogni weekend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Runbook di reazione (anche prima dell’autoscaler)
|
||||||
|
|
||||||
|
### Sintomo: YouTube “in programma” / niente video con molte live
|
||||||
|
|
||||||
|
1. `docker stats` / CPU Sidekiq sul dedicated
|
||||||
|
2. Contare relay: log `[YoutubeRelay] started` vs sessioni `platform=youtube` live
|
||||||
|
3. Se CPU ~100% e MediaMTX ok → **accendere overflow** (Fase 1) o abbassare nuove live YT
|
||||||
|
4. Se MediaMTX alto / path non ready → overflow **non** aiuta; ridurre reader o spostare HLS, non aggiungere pull Cloud ciechi
|
||||||
|
|
||||||
|
### Sintomo: live YT che si spezza solo sulle VM Cloud
|
||||||
|
|
||||||
|
1. RTT e packet loss dedicated↔VM
|
||||||
|
2. Verificare che intake usi RTMP interno, non HLS pubblico via Internet
|
||||||
|
3. Controllare doppio owner / due ffmpeg sulla stessa key
|
||||||
|
|
||||||
|
### Sintomo: bolletta Cloud anomala
|
||||||
|
|
||||||
|
1. Lista server con label `matchlivetv-overflow`
|
||||||
|
2. Destroy idle
|
||||||
|
3. Abbassare warm pool
|
||||||
|
|
||||||
|
### Sintomo: job replay morti dopo scale-in
|
||||||
|
|
||||||
|
1. Verificare che overflow **non** consumi coda `default` / `recordings`
|
||||||
|
2. Riprocessare dead set Sidekiq
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Criteri di successo
|
||||||
|
|
||||||
|
| Criterio | Misura |
|
||||||
|
|----------|--------|
|
||||||
|
| Picco gestito | ≥15 live YT contemporanee stabili in test |
|
||||||
|
| Continuità prodotto | Drop telefono → slate YouTube ancora attiva (invariato) |
|
||||||
|
| Costo | Overflow acceso solo in finestre picco; €/h documentato |
|
||||||
|
| Ops | Runbook Fase 1 eseguibile in <10 min da operatore |
|
||||||
|
| Non regressione | Live solo-sito (`matchlivetv`) e replay invariati |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Riepilogo esecutivo
|
||||||
|
|
||||||
|
Il pezzo che manca oltre ~10 live YouTube è **CPU dei relay**, non l’idea MediaMTX.
|
||||||
|
Hetzner Cloud è la leva economica giusta se:
|
||||||
|
|
||||||
|
1. scalate **solo** i worker `YoutubeRelay`,
|
||||||
|
2. tenete MediaMTX caldo sul dedicated,
|
||||||
|
3. risolvete **rete privata** dedicated↔Cloud,
|
||||||
|
4. partite da **warm pool + runbook manuale**, poi automate.
|
||||||
|
|
||||||
|
Il rischio principale non è “creare una VM”, è **leggere N volte MediaMTX e saturare uplink/MediaMTX** mentre pensate di aver risolto solo la CPU. La Fase 0 (misura) decide se l’overflow è sufficiente o se serve anche più ingest/baseline hardware.
|
||||||
|
)
|
||||||
+3
-1
@@ -14,7 +14,9 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
|||||||
|
|
||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
|
# Opzionale: email dedicata di supporto (default = info@matchlivetv.it)
|
||||||
|
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||||
|
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ RAILS_LOG_LEVEL=info
|
|||||||
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
||||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||||
|
# Opzionale: email di supporto App Store (default = info@matchlivetv.it)
|
||||||
|
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||||
PRIVACY_CONTROLLER_VAT=
|
PRIVACY_CONTROLLER_VAT=
|
||||||
|
|
||||||
# Email transazionali (reset password, inviti)
|
# Email transazionali (reset password, inviti)
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ services:
|
|||||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
|
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
|
||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
||||||
@@ -150,6 +151,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./nginx-edge/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./nginx-edge/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
- ./nginx-edge/maintenance.html:/usr/share/nginx/maintenance/index.html:ro
|
- ./nginx-edge/maintenance.html:/usr/share/nginx/maintenance/index.html:ro
|
||||||
|
- ./nginx-edge/well-known:/usr/share/nginx/well-known:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/edge-health"]
|
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/edge-health"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
|
|||||||
@@ -70,8 +70,9 @@ services:
|
|||||||
RAILS_LOG_TO_STDOUT: "true"
|
RAILS_LOG_TO_STDOUT: "true"
|
||||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||||
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>}
|
SUPPORT_CONTACT_EMAIL: ${SUPPORT_CONTACT_EMAIL:-}
|
||||||
|
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
garage.prod.toml
|
||||||
|
prod-credentials.env
|
||||||
@@ -98,6 +98,13 @@ http {
|
|||||||
return 200 "ok\n";
|
return 200 "ok\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Android App Links (Play Console / Digital Asset Links)
|
||||||
|
location = /.well-known/assetlinks.json {
|
||||||
|
default_type application/json;
|
||||||
|
add_header Cache-Control "public, max-age=300";
|
||||||
|
alias /usr/share/nginx/well-known/assetlinks.json;
|
||||||
|
}
|
||||||
|
|
||||||
# Health check ops: pass-through senza pagina di cortesia.
|
# Health check ops: pass-through senza pagina di cortesia.
|
||||||
location = /up {
|
location = /up {
|
||||||
proxy_pass http://$rails_backend;
|
proxy_pass http://$rails_backend;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"relation": [
|
||||||
|
"delegate_permission/common.handle_all_urls",
|
||||||
|
"delegate_permission/common.get_login_creds"
|
||||||
|
],
|
||||||
|
"target": {
|
||||||
|
"namespace": "android_app",
|
||||||
|
"package_name": "com.matchlivetv.match_live_tv",
|
||||||
|
"sha256_cert_fingerprints": [
|
||||||
|
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||||
|
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
*.iml
|
*.iml
|
||||||
/captures/
|
/captures/
|
||||||
/app/build/
|
/app/build/
|
||||||
|
/dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
keystore.properties
|
keystore.properties
|
||||||
keystore/
|
keystore/
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
|
import java.io.File
|
||||||
import java.util.Properties
|
import java.util.Properties
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipFile
|
||||||
|
import java.util.zip.ZipOutputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
@@ -20,13 +24,18 @@ android {
|
|||||||
applicationId = "com.matchlivetv.match_live_tv"
|
applicationId = "com.matchlivetv.match_live_tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 26
|
versionCode = 31
|
||||||
versionName = "2.0.5-native"
|
versionName = "2.0.10-native"
|
||||||
|
|
||||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||||
?: "https://www.matchlivetv.it"
|
?: "https://www.matchlivetv.it"
|
||||||
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// Anche in defaultConfig: AGP estrae simboli se le .so non sono già stripped.
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "SYMBOL_TABLE"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -47,6 +56,11 @@ android {
|
|||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro",
|
"proguard-rules.pro",
|
||||||
)
|
)
|
||||||
|
// Le .so AndroidX arrivano già stripped: AGP non genera metadata.
|
||||||
|
// Il task injectNativeDebugSymbolsInReleaseBundle le incorpora nell'AAB.
|
||||||
|
ndk {
|
||||||
|
debugSymbolLevel = "SYMBOL_TABLE"
|
||||||
|
}
|
||||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||||
signingConfigs.getByName("release")
|
signingConfigs.getByName("release")
|
||||||
} else {
|
} else {
|
||||||
@@ -108,3 +122,118 @@ dependencies {
|
|||||||
androidTestImplementation("androidx.test:core:1.6.1")
|
androidTestImplementation("androidx.test:core:1.6.1")
|
||||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Play Console: le .so prebuilt (AndroidX) sono già stripped → AGP salta l'estrazione
|
||||||
|
// (niente BUNDLE-METADATA/com.android.tools.build.debugsymbols). Generiamo i .so.sym
|
||||||
|
// come fa AGP (llvm-objcopy --strip-debug) e li iniettiamo nell'intermediary AAB.
|
||||||
|
fun findLlvmObjcopy(): File {
|
||||||
|
val ndkRoot = File(android.sdkDirectory, "ndk")
|
||||||
|
val ndkDirs = ndkRoot.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.name }.orEmpty()
|
||||||
|
for (ndk in ndkDirs) {
|
||||||
|
val candidate = ndk.resolve("toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy")
|
||||||
|
if (candidate.isFile) return candidate
|
||||||
|
}
|
||||||
|
error("llvm-objcopy non trovato sotto ${ndkRoot.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val injectNativeDebugSymbolsInReleaseBundle by tasks.registering {
|
||||||
|
description = "Embed native-debug-symbols.zip (.so.sym) into the release AAB intermediary"
|
||||||
|
val mergeNative = tasks.named("mergeReleaseNativeLibs")
|
||||||
|
val packageBundle = tasks.named("packageReleaseBundle")
|
||||||
|
dependsOn(mergeNative, packageBundle)
|
||||||
|
inputs.dir(
|
||||||
|
layout.buildDirectory.dir(
|
||||||
|
"intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inputs.file(
|
||||||
|
layout.buildDirectory.file(
|
||||||
|
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
outputs.file(
|
||||||
|
layout.buildDirectory.file(
|
||||||
|
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
doLast {
|
||||||
|
val libsDir = layout.buildDirectory
|
||||||
|
.dir("intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib")
|
||||||
|
.get()
|
||||||
|
.asFile
|
||||||
|
val aabFile = layout.buildDirectory
|
||||||
|
.file("intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab")
|
||||||
|
.get()
|
||||||
|
.asFile
|
||||||
|
require(libsDir.isDirectory) { "Native libs missing: $libsDir" }
|
||||||
|
require(aabFile.isFile) { "Intermediary AAB missing: $aabFile" }
|
||||||
|
|
||||||
|
val objcopy = findLlvmObjcopy()
|
||||||
|
val workDir = layout.buildDirectory.dir("tmp/native-debug-symbols-inject").get().asFile
|
||||||
|
workDir.deleteRecursively()
|
||||||
|
workDir.mkdirs()
|
||||||
|
val symbolsRoot = workDir.resolve("symbols")
|
||||||
|
symbolsRoot.mkdirs()
|
||||||
|
|
||||||
|
var count = 0
|
||||||
|
libsDir.walkTopDown().filter { it.isFile && it.extension == "so" }.forEach { so ->
|
||||||
|
val rel = so.relativeTo(libsDir).invariantSeparatorsPath
|
||||||
|
val outRel = "$rel.sym"
|
||||||
|
val outFile = symbolsRoot.resolve(outRel)
|
||||||
|
outFile.parentFile.mkdirs()
|
||||||
|
// Allineato ad AGP SYMBOL_TABLE: llvm-objcopy --strip-debug → *.so.sym
|
||||||
|
val proc = ProcessBuilder(
|
||||||
|
objcopy.absolutePath,
|
||||||
|
"--strip-debug",
|
||||||
|
so.absolutePath,
|
||||||
|
outFile.absolutePath,
|
||||||
|
).redirectErrorStream(true).start()
|
||||||
|
val out = proc.inputStream.bufferedReader().readText()
|
||||||
|
check(proc.waitFor() == 0) {
|
||||||
|
"objcopy failed for $rel: $out"
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
check(count > 0) { "Nessuna .so da cui generare simboli in $libsDir" }
|
||||||
|
|
||||||
|
val symbolsZip = workDir.resolve("native-debug-symbols.zip")
|
||||||
|
ZipOutputStream(symbolsZip.outputStream().buffered()).use { zos ->
|
||||||
|
symbolsRoot.walkTopDown().filter { it.isFile }.forEach { f ->
|
||||||
|
val rel = f.relativeTo(symbolsRoot).invariantSeparatorsPath
|
||||||
|
zos.putNextEntry(ZipEntry(rel))
|
||||||
|
f.inputStream().use { it.copyTo(zos) }
|
||||||
|
zos.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val metaPath = "BUNDLE-METADATA/com.android.tools.build.debugsymbols/native-debug-symbols.zip"
|
||||||
|
val outAab = workDir.resolve("intermediary-with-symbols.aab")
|
||||||
|
ZipFile(aabFile).use { src ->
|
||||||
|
ZipOutputStream(outAab.outputStream().buffered()).use { dst ->
|
||||||
|
val entries = src.entries()
|
||||||
|
while (entries.hasMoreElements()) {
|
||||||
|
val entry = entries.nextElement()
|
||||||
|
if (entry.name == metaPath) continue
|
||||||
|
val newEntry = ZipEntry(entry.name)
|
||||||
|
newEntry.time = entry.time
|
||||||
|
// Deflate all entries when rewriting; STORED copy is fragile across ZipFile.
|
||||||
|
newEntry.method = ZipEntry.DEFLATED
|
||||||
|
dst.putNextEntry(newEntry)
|
||||||
|
src.getInputStream(entry).use { it.copyTo(dst) }
|
||||||
|
dst.closeEntry()
|
||||||
|
}
|
||||||
|
dst.putNextEntry(ZipEntry(metaPath))
|
||||||
|
symbolsZip.inputStream().use { it.copyTo(dst) }
|
||||||
|
dst.closeEntry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
outAab.copyTo(aabFile, overwrite = true)
|
||||||
|
logger.lifecycle(
|
||||||
|
"Injected $metaPath ($count .so.sym, ${symbolsZip.length()} bytes) into ${aabFile.name} via ${objcopy.name}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.matching { it.name == "signReleaseBundle" }.configureEach {
|
||||||
|
dependsOn(injectNativeDebugSymbolsInReleaseBundle)
|
||||||
|
}
|
||||||
|
|||||||
+7
-6
@@ -40,12 +40,13 @@ class ThermalStateManager(
|
|||||||
fun start() {
|
fun start() {
|
||||||
monitor.onStateChanged = { next ->
|
monitor.onStateChanged = { next ->
|
||||||
val previous = _state.value
|
val previous = _state.value
|
||||||
if (next == previous) return@onStateChanged
|
if (next != previous) {
|
||||||
Log.i(TAG, "[Thermal] $previous → $next")
|
Log.i(TAG, "[Thermal] $previous → $next")
|
||||||
_state.value = next
|
_state.value = next
|
||||||
_noticeMessage.value = userNotice(next, previous)
|
_noticeMessage.value = userNotice(next, previous)
|
||||||
applyAdaptation(next, force = false)
|
applyAdaptation(next, force = false)
|
||||||
updateCriticalWatch(next)
|
updateCriticalWatch(next)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
monitor.start()
|
monitor.start()
|
||||||
_state.value = monitor.currentState
|
_state.value = monitor.currentState
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user