Compare commits

...
Author SHA1 Message Date
eminuxandCursor f92ab3a813 Aggiunge bitrate adattivo con qualità minima da regia e telemetria del telefono.
L'encoder scende in congestione e risale piano; la regia imposta il pavimento, mostra lo stato del telefono e apre il selettore dal bitrate colorato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 20:05:16 +02:00
eminuxandCursor bc2257efd2 Aggiunge la pagina pubblica /support compatibile con App Store Review.
Fornisce assistenza multilingua senza CTA commerciali e riusa l’email di supporto configurabile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:37:20 +02:00
Emiliano FrascaroandCursor 4af4fa68ac Prepara iOS build 32 per App Store: icona opaca e orientamenti iPad.
Rimuove l'alpha dall'AppIcon e dichiara tutte le orientazioni richieste dal multitasking iPad, così l'upload ASC non fallisce più.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 22:58:27 +02:00
Emiliano FrascaroandCursor 45bdda7c95 Semplifica la home partite a un solo CTA e sistema la nav iOS.
Rimuove il pulsante ridondante «Partita programmata», migliora il padding dei bottoni e fa di splash/login/matches root vere così non compare più il chevron indietro spurio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:54:44 +02:00
Emiliano FrascaroandCursor b926531447 Allinea la versione iOS a Android 2.0.10 e aggiorna la doc di gap.
Bump marketing/build a 2.0.10/31, copy EN forgot password e checklist password policy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:39:43 +02:00
eminux da6fc1d523 Merge branch 'feature/password-policy'
Policy password, errori API localizzati, fix UI mobile marketing e handoff iOS.
2026-08-08 14:19:35 +02:00
eminuxandCursor 1d1cbf9f3f Localizza errori API, sistema layout mobile e documenta allineamento iOS.
Gli header Accept-Language dalle app e i fix di padding/menu sul web chiudono il giro password-policy; il doc guida il lavoro su Mac.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 14:14:42 +02:00
eminuxandCursor dd9901519a Rifiuta il riuso della password attuale in cambio e reset.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:31:47 +02:00
eminuxandCursor 53449c5d3c Allinea le password di seed alla nuova policy di complessità.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:30:51 +02:00
eminuxandCursor db908e3109 Rafforza i requisiti password con regole di complessità di mercato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 11:30:37 +02:00
eminux 5857f60d79 Merge branch 'feature/account-management'
Gestione account MVP: profilo, cambio password e forgot password su web/Android/iOS.
2026-08-08 10:39:08 +02:00
eminuxandCursor 83a804a709 Aggiunge gestione account con cambio password su web e app.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 10:38:57 +02:00
eminuxandCursor b8694a6b7b Corregge i simboli nativi AAB in formato .so.sym per Play Console.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:45:29 +02:00
eminuxandCursor 5daada81b0 Incorpora i simboli nativi nell’AAB per Play Console.
Le .so AndroidX sono già stripped: AGP non generava metadata; ora le iniettiamo nel bundle prima della firma (release 2.0.9 / 30).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:36:39 +02:00
eminuxandCursor d85bab30d1 Ignora i secret Garage locali nel deploy sync.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:27:25 +02:00
eminuxandCursor 7b3256c8a0 Alleggerisce il relay YouTube e rilascia Android 2.0.8.
ffmpeg fa solo remux copy A/V; le app fissano AAC 48 kHz mono; sync deploy non cancella più garage.prod.toml.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:25:40 +02:00
eminuxandCursor 9bc89fceba Allinea il fingerprint Garage negli health check ops.
Così al ripristino dello storage gli incidenti garage_storage:head si chiudono da soli invece di restare aperti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 16:29:03 +02:00
eminuxandCursor d5ec266437 Pubblica App Links Android e prepara la release 2.0.7.
Serve assetlinks su edge/Rails, documenta overflow Hetzner e bumpa l'AAB a 28 con simboli nativi per Play.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 19:44:17 +02:00
eminuxandCursor a448ad59ee Allinea i doc al flusso live senza OverlayRelay.
Documenta overlay in app, YoutubeRelay copy+AAC in Sidekiq
e HLS sul path camera; aggiorna checklist e troubleshooting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 23:05:50 +02:00
eminuxandCursor 74a156cedc Corregge i contatti email al dominio matchlivetv.it.
I mailto di prezzi/billing e i default privacy/mailer puntavano
ancora a matchlive.it, dominio brand incoerente.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:36:42 +02:00
eminuxandCursor 97d787f758 Bump Android a 2.0.6 e corregge la compilazione termica.
Alza versionCode a 27 per il test chiuso Play Console e sistema
l'etichetta Kotlin invalida in ThermalStateManager.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:02:35 +02:00
165 changed files with 7104 additions and 796 deletions
+2
View File
@@ -55,6 +55,8 @@ 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 "set_min_quality"
Sessions::SetMinQuality.new(session, preset: data["min_quality_preset"]).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)
@@ -68,6 +68,7 @@ module Api
current_bitrate: params[:current_bitrate], current_bitrate: params[:current_bitrate],
target_bitrate: params[:target_bitrate], target_bitrate: params[:target_bitrate],
fps: params[:fps], fps: params[:fps],
thermal_state: sanitized_thermal_state,
last_seen_at: Time.current last_seen_at: Time.current
) )
sync_publisher_when_streaming!(params[:fps].to_f) sync_publisher_when_streaming!(params[:fps].to_f)
@@ -141,6 +142,13 @@ module Api
} }
end end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: session_json(@session)
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def youtube_stats def youtube_stats
count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present? count = if @session.platform == "youtube" && @session.youtube_broadcast_id.present?
Youtube::BroadcastService.new(@session.match.team) Youtube::BroadcastService.new(@session.match.team)
@@ -160,6 +168,11 @@ module Api
.find(params[:id]) .find(params[:id])
end end
def sanitized_thermal_state
value = params[:thermal_state].to_s.downcase.presence
value if value.in?(%w[nominal fair serious critical])
end
def session_params def session_params
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel) params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
end end
@@ -187,6 +200,7 @@ module Api
youtube_ready: session.youtube_ready?, youtube_ready: session.youtube_ready?,
privacy_status: session.privacy_status, privacy_status: session.privacy_status,
quality_preset: session.quality_preset, quality_preset: session.quality_preset,
min_quality_preset: session.min_quality_preset,
target_bitrate: session.target_bitrate, target_bitrate: session.target_bitrate,
started_at: session.started_at, started_at: session.started_at,
disconnection_count: session.disconnection_count disconnection_count: session.disconnection_count
@@ -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 min_quality]
before_action :set_session_from_token before_action :set_session_from_token
@@ -61,8 +61,20 @@ module Public
render json: status_payload render json: status_payload
end end
def min_quality
Sessions::SetMinQuality.new(@session, preset: params[:min_quality_preset]).call
render json: status_payload
rescue ArgumentError => e
render json: status_payload.merge(error: e.message), status: :unprocessable_entity
end
private private
def camera_telemetry_payload(publisher_online)
cam = @session.device_states.find_by(device_role: "camera")
(cam&.as_regia_payload || {}).merge(publisher_online: publisher_online)
end
def set_session_from_token def set_session_from_token
@session = Sessions::RegiaAccess.find_session_by_token(params[:token]) @session = Sessions::RegiaAccess.find_session_by_token(params[:token])
return if @session return if @session
@@ -86,7 +98,10 @@ module Public
home_name: @session.match.team.name, home_name: @session.match.team.name,
away_name: @session.match.opponent_name, away_name: @session.match.opponent_name,
points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1), points_target: Scoring::Rules.points_target_for_match(@session.match, current_set: score&.current_set || 1),
score: score&.as_cable_payload score: score&.as_cable_payload,
min_quality_preset: @session.min_quality_preset,
min_quality_options: Sessions::SelectQuality.allowed_min_ids(@session.quality_preset),
telemetry: camera_telemetry_payload(publisher_online)
} }
end end
end end
@@ -11,6 +11,7 @@ module Public
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" }, { loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" }, { loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" } { loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
] ]
+20 -1
View File
@@ -54,15 +54,34 @@ module Public
copyPrompt: t("regia.js.copy_prompt"), copyPrompt: t("regia.js.copy_prompt"),
shareRegiaText: t("regia.js.share_regia_text"), shareRegiaText: t("regia.js.share_regia_text"),
shareLiveText: t("regia.js.share_live_text"), shareLiveText: t("regia.js.share_live_text"),
minQualityUpdated: t("regia.js.min_quality_updated"),
minQualityError: t("regia.js.min_quality_error"),
minQualityAuto: t("regia.min_quality.auto"),
previewWaiting: t("regia.preview_waiting"), previewWaiting: t("regia.preview_waiting"),
streamEnded: t("regia.preview_ended"), streamEnded: t("regia.preview_ended"),
previewHide: t("regia.preview_hide"),
previewShow: t("regia.preview_show"),
previewOffHint: t("regia.preview_off_hint"),
resumeLabel: t("regia.resume"), resumeLabel: t("regia.resume"),
pauseLabel: t("regia.pause"), pauseLabel: t("regia.pause"),
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"),
waitingBadge: t("regia.status.waiting"), waitingBadge: t("regia.status.waiting"),
subtitlePrefix: t("regia.subtitle_prefix") subtitlePrefix: t("regia.subtitle_prefix"),
telemetryWaiting: t("regia.telemetry.waiting"),
telemetryStale: t("regia.telemetry.stale"),
telemetryJustNow: t("regia.telemetry.just_now"),
telemetrySecondsAgo: t("regia.telemetry.seconds_ago"),
telemetryPublisherOn: t("regia.telemetry.publisher_on"),
telemetryPublisherOff: t("regia.telemetry.publisher_off"),
telemetryThermalNominal: t("regia.telemetry.thermal_nominal"),
telemetryThermalFair: t("regia.telemetry.thermal_fair"),
telemetryThermalSerious: t("regia.telemetry.thermal_serious"),
telemetryThermalCritical: t("regia.telemetry.thermal_critical"),
telemetryBitrateMax: t("regia.telemetry.bitrate_max"),
telemetryBitrateTitle: t("regia.telemetry.bitrate_title"),
telemetryBitrateTap: t("regia.telemetry.bitrate_tap")
}.to_json }.to_json
end end
+2
View File
@@ -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
+13
View File
@@ -16,4 +16,17 @@ class DeviceState < ApplicationRecord
status: stream_session.status status: stream_session.status
} }
end end
def as_regia_payload
{
battery: battery_level,
network: network_type,
signal: signal_strength,
bitrate: current_bitrate,
target_bitrate: target_bitrate,
fps: fps,
thermal_state: thermal_state,
last_seen_at: last_seen_at&.iso8601
}
end
end end
+1
View File
@@ -14,6 +14,7 @@ class StreamSession < ApplicationRecord
validates :platform, inclusion: { in: PLATFORMS } validates :platform, inclusion: { in: PLATFORMS }
validates :status, inclusion: { in: STATUSES } validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES } validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :min_quality_preset, inclusion: { in: Sessions::SelectQuality::MIN_QUALITY_IDS }
before_validation :normalize_privacy_status before_validation :normalize_privacy_status
before_validation :ensure_publish_token, on: :create before_validation :ensure_publish_token, on: :create
+2
View File
@@ -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
+1 -1
View File
@@ -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
+4 -3
View File
@@ -9,11 +9,12 @@ module Sessions
@session.pause! if @session.may_pause? @session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id) Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin begin
Mediamtx::Client.new.set_path_recording(@session, enabled: false) mtx = Mediamtx::Client.new
mtx.set_path_recording(@session, enabled: false)
mtx.set_always_available(@session, enabled: true)
rescue Mediamtx::Client::Error => e rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] disable recording: #{e.message}") Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
end end
Mediamtx::Client.new.set_always_available(@session, enabled: true)
# Slate su path per HLS in pausa; RTMP telefono si ferma via comando app. # Slate su path per HLS in pausa; RTMP telefono si ferma via comando app.
log_event("paused") log_event("paused")
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
@@ -3,6 +3,8 @@ module Sessions
class SelectQuality class SelectQuality
UPLOAD_HEADROOM = 0.8 UPLOAD_HEADROOM = 0.8
UPLOAD_HEADROOM_1080P = 1.0 UPLOAD_HEADROOM_1080P = 1.0
AUTO_ID = "auto"
AUTO_FLOOR_BITRATE = 800_000
PRESETS = [ PRESETS = [
{ id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 }, { id: "1080p_30_4.5mbps", target_bitrate: 4_500_000, target_fps: 30 },
@@ -11,6 +13,32 @@ module Sessions
{ id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 } { id: "720p_30_1.5mbps", target_bitrate: 1_500_000, target_fps: 30 }
].freeze ].freeze
MIN_QUALITY_IDS = ([AUTO_ID] + PRESETS.map { |preset| preset[:id] }).freeze
def self.floor_bitrate(preset_id)
return AUTO_FLOOR_BITRATE if preset_id.blank? || preset_id == AUTO_ID
PRESETS.find { |preset| preset[:id] == preset_id }&.fetch(:target_bitrate) || AUTO_FLOOR_BITRATE
end
def self.allowed_min_ids(session_preset)
ids = [AUTO_ID] + PRESETS.reject { |preset| preset[:id].start_with?("1080p") }.reverse.map { |preset| preset[:id] }
ids << "1080p_30_4.5mbps" if session_preset.to_s.start_with?("1080p")
ids
end
def self.display_name(preset_id)
return AUTO_ID if preset_id.blank? || preset_id == AUTO_ID
preset = PRESETS.find { |item| item[:id] == preset_id }
return preset_id unless preset
resolution = preset[:id].start_with?("1080p") ? "1080p" : "720p"
mbps = preset[:target_bitrate] / 1_000_000.0
mbps_text = (mbps == mbps.to_i) ? mbps.to_i.to_s : format("%.1f", mbps)
"#{resolution} · #{mbps_text} Mbps"
end
def self.call(upload_mbps:) def self.call(upload_mbps:)
new(upload_mbps: upload_mbps).call new(upload_mbps: upload_mbps).call
end end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
module Sessions
class SetMinQuality
def initialize(session, preset:)
@session = session
@preset = preset.to_s.strip
end
def call
id = normalize
unless SelectQuality.allowed_min_ids(@session.quality_preset).include?(id)
raise ArgumentError, "Qualità minima non valida"
end
return @session if @session.min_quality_preset == id
@session.update!(min_quality_preset: id)
@session.stream_events.create!(
event_type: "quality_changed",
occurred_at: Time.current,
metadata: { "min_quality_preset" => id }
)
SessionChannel.broadcast_message(
@session,
{ type: "command", action: "set_min_quality", min_quality_preset: id }
)
@session
end
private
def normalize
return SelectQuality::AUTO_ID if @preset.blank? || @preset == SelectQuality::AUTO_ID
@preset
end
end
end
+13 -14
View File
@@ -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
if mode == :rtmp common_head = [
return [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning", "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt", "-fflags", "+genpts+discardcorrupt"
]
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
if mode == :rtmp
return common_head + [
"-analyzeduration", "10000000", "-probesize", "10000000", "-analyzeduration", "10000000", "-probesize", "10000000",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-i", url, "-i", url
"-c:v", "copy", ] + copy_tail
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
"-bsf:a", "aac_adtstoasc",
"-f", "flv", output
]
end end
[ common_head + [
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
"-fflags", "+genpts+discardcorrupt",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-live_start_index", "-1", "-live_start_index", "-1",
"-i", url, "-i", url,
"-c:v", "copy", "-c:v", "copy",
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1", "-c:a", "copy",
"-bsf:a", "aac_adtstoasc", "-bsf:a", "aac_adtstoasc",
"-f", "flv", output "-f", "flv", output
] ]
@@ -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
+3 -3
View File
@@ -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 %>
+1 -1
View File
@@ -7,7 +7,7 @@
<meta name="theme-color" content="#0a0a0e"> <meta name="theme-color" content="#0a0a0e">
<title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title> <title><%= content_for?(:title) ? yield(:title) : t("regia.meta_title_fallback") %></title>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="/regia.css?v=1"> <link rel="stylesheet" href="/regia.css?v=11">
<%= yield :head %> <%= yield :head %>
</head> </head>
<body class="regia-body"> <body class="regia-body">
@@ -0,0 +1,56 @@
<% content_for :title, t("auth.account.meta_title") %>
<% content_for :meta_description, t("auth.account.meta_description") %>
<% content_for :robots, "noindex, nofollow" %>
<section class="auth-page">
<h1><%= t("auth.account.title") %></h1>
<div class="card" style="margin-bottom:1.5rem">
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.profile_heading") %></h2>
<%= form_with url: public_account_path, method: :patch, local: true do %>
<p>
<label for="account_email"><%= t("auth.email") %></label><br>
<input type="email" id="account_email" value="<%= current_user.email %>" readonly
class="input" autocomplete="username"
style="width:100%;opacity:0.75;cursor:not-allowed">
</p>
<p class="muted" style="margin-top:-0.5rem;font-size:0.9rem">
<%= t("auth.account.role_label", role: current_user.role) %>
</p>
<%= render "shared/input_toggle",
name: :name,
label: t("auth.account.name_label"),
value: current_user.name,
required: true,
autocomplete: "name",
masked: false %>
<%= submit_tag t("auth.account.save_profile"), class: "btn btn-primary" %>
<% end %>
</div>
<div class="card">
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
<%= render "shared/input_toggle",
name: :current_password,
label: t("auth.account.current_password_label"),
input_type: "password",
required: true,
autocomplete: "current-password" %>
<%= render "shared/input_toggle",
name: :password,
label: t("auth.account.new_password_label"),
input_type: "password",
required: true,
autocomplete: "new-password" %>
<%= render "shared/input_toggle",
name: :password_confirmation,
label: t("auth.password_confirmation"),
input_type: "password",
required: true,
autocomplete: "new-password" %>
<%= submit_tag t("auth.account.save_password"), class: "btn btn-primary" %>
<% end %>
</div>
</section>
@@ -13,7 +13,7 @@
<% if @subscription.admin_comped_reason.present? %> <% if @subscription.admin_comped_reason.present? %>
(<%= @subscription.admin_comped_reason %>) (<%= @subscription.admin_comped_reason %>)
<% end %>. <% end %>.
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlive.it", "info@matchlive.it")) %> <%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlivetv.it", "info@matchlivetv.it")) %>
</p> </p>
</div> </div>
<% else %> <% else %>
@@ -2,7 +2,8 @@
<% content_for :meta_description, t("auth.invitation.meta_description") %> <% content_for :meta_description, t("auth.invitation.meta_description") %>
<% content_for :robots, "noindex, nofollow" %> <% content_for :robots, "noindex, nofollow" %>
<div class="card" style="max-width:480px"> <section class="auth-page">
<div class="card">
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1> <h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p> <p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
<p class="muted" style="font-size:0.9rem;margin-bottom:16px"> <p class="muted" style="font-size:0.9rem;margin-bottom:16px">
@@ -23,4 +24,5 @@
<%= t("auth.invitation.mobile_app_label") %> <%= t("auth.invitation.mobile_app_label") %>
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a> <a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
</p> </p>
</div> </div>
</section>
@@ -46,6 +46,7 @@
<h3><%= t("legal.cookies.s4_1_title") %></h3> <h3><%= t("legal.cookies.s4_1_title") %></h3>
<p><%= t("legal.cookies.s4_1_intro") %></p> <p><%= t("legal.cookies.s4_1_intro") %></p>
<div class="table-scroll">
<table class="legal-table"> <table class="legal-table">
<thead> <thead>
<tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr> <tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
@@ -65,6 +66,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
<h3><%= t("legal.cookies.s4_2_title") %></h3> <h3><%= t("legal.cookies.s4_2_title") %></h3>
<p> <p>
@@ -75,6 +77,7 @@
<% else %> <% else %>
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p> <p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
<% end %> <% end %>
<div class="table-scroll">
<table class="legal-table"> <table class="legal-table">
<thead> <thead>
<tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr> <tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
@@ -100,6 +103,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
<p> <p>
<%= raw t( <%= raw t(
"legal.cookies.s4_2_p2_html", "legal.cookies.s4_2_p2_html",
@@ -22,6 +22,7 @@
<%= render "shared/plan_cards" %> <%= render "shared/plan_cards" %>
<div class="table-scroll">
<table class="compare-table"> <table class="compare-table">
<thead> <thead>
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr> <tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
@@ -36,10 +37,11 @@
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr> <tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
</tbody> </tbody>
</table> </table>
</div>
<div class="card" style="margin-top:32px;text-align:center"> <div class="card" style="margin-top:32px;text-align:center">
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3> <h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p> <p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
<%= mail_to "info@matchlive.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %> <%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
</div> </div>
</div> </div>
@@ -77,6 +77,7 @@
<section> <section>
<h2><%= t("legal.privacy.s5_title") %></h2> <h2><%= t("legal.privacy.s5_title") %></h2>
<div class="table-scroll">
<table class="legal-table"> <table class="legal-table">
<thead> <thead>
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr> <tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
@@ -108,6 +109,7 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div>
</section> </section>
<section> <section>
@@ -0,0 +1,38 @@
<% content_for :title, t("legal.support.title") %>
<% content_for :meta_description, t("legal.support.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
<div class="wrap legal-doc">
<h1><%= t("legal.support.h1") %></h1>
<p><%= t("legal.support.intro") %></p>
<p>
<%= t("legal.support.email_label") %>
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
</p>
<section>
<h2><%= t("legal.support.access_title") %></h2>
<p><%= t("legal.support.access_body") %></p>
</section>
<section>
<h2><%= t("legal.support.live_title") %></h2>
<p><%= t("legal.support.live_body") %></p>
</section>
<section>
<h2><%= t("legal.support.team_title") %></h2>
<p><%= t("legal.support.team_body") %></p>
</section>
<section>
<h2><%= t("legal.support.privacy_title") %></h2>
<p>
<%= raw t(
"legal.support.privacy_body_html",
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
) %>
</p>
</section>
</div>
@@ -4,6 +4,7 @@
<section class="auth-page"> <section class="auth-page">
<h1><%= t("auth.password_reset.title") %></h1> <h1><%= t("auth.password_reset.title") %></h1>
<div class="card"> <div class="card">
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
<%= form_with url: public_password_reset_path, method: :patch, local: true do %> <%= form_with url: public_password_reset_path, method: :patch, local: true do %>
<%= hidden_field_tag :token, @token %> <%= hidden_field_tag :token, @token %>
<%= render "shared/input_toggle", <%= render "shared/input_toggle",
@@ -21,4 +21,4 @@
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px"></button> <button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px"></button>
</div> </div>
</div> </div>
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px"><%= t("regia.board.close_set") %></button> <button type="button" class="regia-btn regia-btn--yellow" data-action="close_set"><%= t("regia.board.close_set") %></button>
+84 -19
View File
@@ -16,6 +16,8 @@
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-stop-url="<%= j public_regia_stop_path(params[:token]) %>" data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
data-min-quality-url="<%= j public_regia_min_quality_path(params[:token]) %>"
data-min-quality="<%= j @session.min_quality_preset %>"
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 %>"
data-home-name="<%= j @team.name %>" data-home-name="<%= j @team.name %>"
@@ -29,40 +31,62 @@
data-initial-paused="<%= @session.paused? %>"> data-initial-paused="<%= @session.paused? %>">
<header class="regia-header"> <header class="regia-header">
<div class="regia-header__text">
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1> <h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
<p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p> <p id="regia-subtitle"><%= t("regia.subtitle_prefix") %> <%= regia_header_subtitle(@match, @score) %></p>
</div>
<span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>"> <span id="regia-badge" class="regia-badge <%= @stream_closed ? 'regia-badge--ended' : (@session.paused? ? 'regia-badge--wait' : (@on_air ? 'regia-badge--live' : 'regia-badge--wait')) %>">
<%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %> <%= @stream_closed ? t("regia.status.ended") : (@session.paused? ? t("regia.status.paused") : (@on_air ? t("regia.status.live") : t("regia.status.waiting"))) %>
</span> </span>
</header> </header>
<nav class="regia-toolbar" aria-label="<%= t("regia.links_nav") %>">
<% if @live_share_url.present? %> <% if @live_share_url.present? %>
<div class="regia-share-group"> <div class="regia-tool-group" role="group" aria-label="<%= t("regia.live_share_label") %>">
<p class="regia-share-label"><%= t("regia.live_share_label") %></p> <span class="regia-tool-group__label"><%= t("regia.live_share_label") %></span>
<div class="regia-share"> <div class="regia-tool-group__btns">
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-live" style="margin:0"> <button type="button" class="regia-icon-btn" id="btn-share-live" aria-label="<%= t("regia.share_live") %>" title="<%= t("regia.share_live") %>">
<%= t("regia.share_live") %> <svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
</button> </button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-live" style="margin:0"> <button type="button" class="regia-icon-btn" id="btn-copy-live" aria-label="<%= t("regia.copy_live") %>" title="<%= t("regia.copy_live") %>">
<%= t("regia.copy_link") %> <svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button> </button>
</div> </div>
</div> </div>
<% end %> <% end %>
<div class="regia-tool-group" role="group" aria-label="<%= t("regia.regia_share_label") %>">
<div class="regia-share-group"> <span class="regia-tool-group__label"><%= t("regia.regia_share_label") %></span>
<p class="regia-share-label"><%= t("regia.regia_share_label") %></p> <div class="regia-tool-group__btns">
<div class="regia-share"> <button type="button" class="regia-icon-btn" id="btn-share-regia" aria-label="<%= t("regia.share_regia") %>" title="<%= t("regia.share_regia") %>">
<button type="button" class="regia-btn regia-btn--outline" id="btn-share-regia" style="margin:0"> <svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
<%= t("regia.share_regia") %>
</button> </button>
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy-regia" style="margin:0"> <button type="button" class="regia-icon-btn" id="btn-copy-regia" aria-label="<%= t("regia.copy_regia") %>" title="<%= t("regia.copy_regia") %>">
<%= t("regia.copy_link") %> <svg class="icon-outline" viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button> </button>
</div> </div>
</div> </div>
<% unless @stream_closed %>
<div class="regia-tool-group regia-tool-group--stream" role="group" aria-label="<%= t("regia.stream_controls_label") %>">
<span class="regia-tool-group__label" aria-hidden="true">&nbsp;</span>
<div class="regia-tool-group__btns">
<button type="button" class="regia-icon-btn" id="btn-toggle-preview" aria-label="<%= t("regia.preview_hide") %>" title="<%= t("regia.preview_hide") %>" aria-pressed="true">
<svg class="icon-fill icon-eye" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg>
<svg class="icon-fill icon-eye-off" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 7c2.76 0 5 2.24 5 5 0 .65-.13 1.26-.36 1.83l2.92 2.92c1.51-1.26 2.7-2.89 3.43-4.75-1.73-4.39-6-7.5-11-7.5-1.4 0-2.74.25-3.98.7l2.16 2.16C10.74 7.13 11.35 7 12 7zM2 4.27l2.28 2.28.46.46C3.08 8.3 1.78 10.02 1 12c1.73 4.39 6 7.5 11 7.5 1.55 0 3.03-.3 4.38-.84l.42.42L19.73 22 21 20.73 3.27 3 2 4.27zM7.53 9.8l1.55 1.55c-.05.21-.08.43-.08.65 0 1.66 1.34 3 3 3 .22 0 .44-.03.65-.08l1.55 1.55c-.67.33-1.41.53-2.2.53-2.76 0-5-2.24-5-5 0-.79.2-1.53.53-2.2zm4.31-.78 3.15 3.15.02-.16c0-1.66-1.34-3-3-3l-.17.01z"/></svg>
</button>
<button type="button" class="regia-icon-btn<%= " is-paused" if @session.paused? %>" id="btn-pause" aria-label="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>" title="<%= @session.paused? ? t("regia.resume") : t("regia.pause") %>">
<svg class="icon-fill icon-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<svg class="icon-fill icon-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
</button>
<button type="button" class="regia-icon-btn regia-icon-btn--danger" id="btn-stop" aria-label="<%= t("regia.stop") %>" title="<%= t("regia.stop") %>">
<svg class="icon-fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6h12v12H6z"/></svg>
</button>
</div>
</div>
<% end %>
</nav>
<div class="regia-preview"> <section class="regia-preview-wrap" id="regia-preview-wrap">
<div class="regia-preview" id="regia-preview-box">
<% unless @stream_closed %> <% unless @stream_closed %>
<video id="regia-preview" playsinline muted autoplay></video> <video id="regia-preview" playsinline muted autoplay></video>
<% end %> <% end %>
@@ -70,14 +94,55 @@
<%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %> <%= @stream_closed ? t("regia.preview_ended") : t("regia.preview_waiting") %>
</div> </div>
</div> </div>
</section>
<section class="regia-scoreboard"> <section class="regia-scoreboard">
<%= render regia_board_partial(@match) %> <%= render regia_board_partial(@match) %>
</section> </section>
<% 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> <section class="regia-telemetry" id="regia-telemetry" aria-label="<%= t("regia.telemetry.label") %>">
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop"><%= t("regia.stop") %></button> <div class="regia-telemetry__row">
<span class="regia-chip" id="tel-battery" title="<%= t("regia.telemetry.battery") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 4h-1V3h-6v1H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H8V6h8v14z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.battery") %></span>
<span class="regia-chip__value"></span>
</span>
<span class="regia-chip" id="tel-network" title="<%= t("regia.telemetry.network") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.network") %></span>
<span class="regia-chip__value"></span>
</span>
<button type="button" class="regia-chip regia-chip--action" id="tel-bitrate" aria-expanded="false" aria-controls="regia-quality" title="<%= t("regia.telemetry.bitrate_tap") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 13h2v-2H3v2zm4 4h2v-6H7v6zm4 4h2V7h-2v14zm4-8h2V9h-2v4zm4-4h2V5h-2v4z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.bitrate") %></span>
<span class="regia-chip__value"></span>
<span class="regia-chip__hint" hidden></span>
</button>
<span class="regia-chip" id="tel-fps" title="<%= t("regia.telemetry.fps") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.fps") %></span>
<span class="regia-chip__value"></span>
</span>
<span class="regia-chip" id="tel-thermal" title="<%= t("regia.telemetry.thermal") %>">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 13V5c0-1.66-1.34-3-3-3S9 3.34 9 5v8c-1.21.91-2 2.37-2 4 0 2.76 2.24 5 5 5s5-2.24 5-5c0-1.63-.79-3.09-2-4zm-3 8c-1.66 0-3-1.34-3-3 0-1.19.7-2.23 1.71-2.72L11 14.73V5c0-.55.45-1 1-1s1 .45 1 1v9.73l.29.18C14.3 15.77 15 16.81 15 18c0 1.66-1.34 3-3 3z"/></svg>
<span class="regia-chip__label"><%= t("regia.telemetry.thermal") %></span>
<span class="regia-chip__value"></span>
</span>
</div>
<div class="regia-quality" id="regia-quality" hidden>
<p class="regia-quality__hint"><%= t("regia.min_quality.recommend") %></p>
<label for="min-quality"><%= t("regia.min_quality.label") %></label>
<select id="min-quality" title="<%= t("regia.min_quality.hint") %>">
<% Sessions::SelectQuality.allowed_min_ids(@session.quality_preset).each do |id| %>
<option value="<%= id %>" <%= "selected" if @session.min_quality_preset == id %>>
<%= id == Sessions::SelectQuality::AUTO_ID ? t("regia.min_quality.auto") : Sessions::SelectQuality.display_name(id) %>
</option>
<% end %>
</select>
</div>
<p class="regia-telemetry__meta" id="tel-meta"><%= t("regia.telemetry.waiting") %></p>
</section>
<% end %> <% end %>
</div> </div>
@@ -94,4 +159,4 @@
</div> </div>
</div> </div>
<script src="/regia.js?v=8"></script> <script src="/regia.js?v=18"></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>
+2 -2
View File
@@ -62,8 +62,8 @@ Rails.application.configure do
# Annotate rendered view with file names. # Annotate rendered view with file names.
config.action_view.annotate_rendered_view_with_filenames = true config.action_view.annotate_rendered_view_with_filenames = true
# Uncomment if you wish to allow Action Cable access from any origin. # Permette Action Cable dall'emulatore Android (10.0.2.2) e da altri host locali.
# config.action_cable.disable_request_forgery_protection = true config.action_cable.disable_request_forgery_protection = true
# Raise error when a before_action's only/except options reference missing actions. # Raise error when a before_action's only/except options reference missing actions.
config.action_controller.raise_on_missing_callback_actions = true config.action_controller.raise_on_missing_callback_actions = true
+7 -1
View File
@@ -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
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+4 -1
View File
@@ -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
+78 -2
View File
@@ -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
@@ -410,16 +432,51 @@ de:
paused: Pausiert paused: Pausiert
live: Live live: Live
waiting: Wartet waiting: Wartet
live_share_label: "Live-Link (Zuschauer)" live_share_label: Live
share_live: Livestream teilen share_live: Livestream teilen
share_action: Teilen
copy_link: Link kopieren copy_link: Link kopieren
regia_share_label: "Regie-Link (Punktestand)" copy_action: Kopieren
copy_live: Live kopieren
copy_regia: Regie kopieren
links_nav: Schnellaktionen
regia_share_label: Regie
stream_controls_label: Live
share_regia: Regie-Link teilen share_regia: Regie-Link teilen
preview_ended: Livestream beendet preview_ended: Livestream beendet
preview_waiting: Vorschau wartet auf Signal… preview_waiting: Vorschau wartet auf Signal…
preview_hide: Vorschau ausblenden
preview_show: Vorschau anzeigen
preview_off_hint: Vorschau aus · kein Video-Download
pause: Pausieren pause: Pausieren
resume: Livestream fortsetzen resume: Livestream fortsetzen
stop: Livestream beenden stop: Livestream beenden
min_quality:
label: Minimale Übertragungsqualität
summary: Qualität
hint: Der Encoder fällt nicht unter dieses Niveau. Automatisch passt die Bitrate an, um Aussetzer zu reduzieren.
recommend: "Automatisch wird empfohlen: passt sich an Netz und Telefon an, ohne das Maximum zu erzwingen."
auto: Automatisch
telemetry:
label: Telefon
waiting: Warte auf das Telefon…
stale: Telefon antwortet nicht
just_now: jetzt
seconds_ago: "vor %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP fehlt
battery: Akku
network: Netz
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps aktuell · max. %{target} Mbps"
bitrate_tap: Tippen, um die Mindestqualität festzulegen
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Warm
thermal_serious: Heiß
thermal_critical: Kritisch
modal: modal:
set_won_title: Satz gewonnen set_won_title: Satz gewonnen
confirm: Satz beenden confirm: Satz beenden
@@ -464,6 +521,8 @@ de:
copy_prompt: "Link kopieren:" copy_prompt: "Link kopieren:"
share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:" share_regia_text: "Öffne diesen Link, um den Live-Spielstand zu verwalten:"
share_live_text: "Sieh dir die Übertragung auf Match Live TV an:" share_live_text: "Sieh dir die Übertragung auf Match Live TV an:"
min_quality_updated: Mindestqualität aktualisiert
min_quality_error: Fehler beim Aktualisieren der Mindestqualität
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
@@ -600,12 +659,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
+73 -2
View File
@@ -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
@@ -410,16 +427,51 @@ en:
paused: Paused paused: Paused
live: Live live: Live
waiting: Waiting waiting: Waiting
live_share_label: "Live link (viewers)" live_share_label: Live
share_live: Share live link share_live: Share live link
share_action: Share
copy_link: Copy link copy_link: Copy link
regia_share_label: "Control room link (score)" copy_action: Copy
copy_live: Copy live
copy_regia: Copy room
links_nav: Quick actions
regia_share_label: Control
stream_controls_label: Live
share_regia: Share control room link share_regia: Share control room link
preview_ended: Stream ended preview_ended: Stream ended
preview_waiting: Waiting for signal… preview_waiting: Waiting for signal…
preview_hide: Hide preview
preview_show: Show preview
preview_off_hint: Preview off · no video download
pause: Pause stream pause: Pause stream
resume: Resume stream resume: Resume stream
stop: End stream stop: End stream
min_quality:
label: Minimum streaming quality
summary: Quality
hint: The encoder will not go below this level. Automatic adapts bitrate to reduce stalls.
recommend: "Automatic is recommended: it adapts to the network and the phone, without forcing maximum quality."
auto: Automatic
telemetry:
label: Phone
waiting: Waiting for the phone…
stale: Phone not responding
just_now: now
seconds_ago: "%{n}s ago"
publisher_on: RTMP ok
publisher_off: RTMP down
battery: Battery
network: Network
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps current · max %{target} Mbps"
bitrate_tap: Tap to set the minimum quality
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Warm
thermal_serious: Hot
thermal_critical: Critical
modal: modal:
set_won_title: Set won set_won_title: Set won
confirm: Close set confirm: Close set
@@ -464,6 +516,8 @@ en:
copy_prompt: "Copy the link:" copy_prompt: "Copy the link:"
share_regia_text: "Open this link to manage the live score:" share_regia_text: "Open this link to manage the live score:"
share_live_text: "Watch the live stream on Match Live TV:" share_live_text: "Watch the live stream on Match Live TV:"
min_quality_updated: Minimum quality updated
min_quality_error: Error updating minimum quality
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
@@ -600,12 +654,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
+78 -2
View File
@@ -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
@@ -410,16 +432,51 @@ es:
paused: En pausa paused: En pausa
live: En directo live: En directo
waiting: En espera waiting: En espera
live_share_label: "Enlace del directo (espectadores)" live_share_label: Directo
share_live: Compartir directo share_live: Compartir directo
share_action: Compartir
copy_link: Copiar enlace copy_link: Copiar enlace
regia_share_label: "Enlace de control (marcador)" copy_action: Copiar
copy_live: Copiar directo
copy_regia: Copiar control
links_nav: Acciones rápidas
regia_share_label: Control
stream_controls_label: Live
share_regia: Compartir enlace de control share_regia: Compartir enlace de control
preview_ended: Directo finalizado preview_ended: Directo finalizado
preview_waiting: Vista previa a la espera de señal… preview_waiting: Vista previa a la espera de señal…
preview_hide: Ocultar vista previa
preview_show: Mostrar vista previa
preview_off_hint: Vista previa apagada · sin descarga de vídeo
pause: Pausar directo pause: Pausar directo
resume: Reanudar directo resume: Reanudar directo
stop: Finalizar directo stop: Finalizar directo
min_quality:
label: Calidad mínima de transmisión
summary: Calidad
hint: El encoder no baja de este nivel. Automática adapta el bitrate para reducir los cortes.
recommend: "Se recomienda Automática: se adapta a la red y al teléfono, sin forzar el máximo."
auto: Automática
telemetry:
label: Teléfono
waiting: Esperando al teléfono…
stale: El teléfono no responde
just_now: ahora
seconds_ago: "hace %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP ausente
battery: Batería
network: Red
bitrate: Bitrate
bitrate_max: "máx %{n}"
bitrate_title: "%{current} Mbps actuales · máximo %{target} Mbps"
bitrate_tap: Toca para definir la calidad mínima
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Caliente
thermal_serious: Muy caliente
thermal_critical: Crítica
modal: modal:
set_won_title: Set ganado set_won_title: Set ganado
confirm: Cerrar set confirm: Cerrar set
@@ -464,6 +521,8 @@ es:
copy_prompt: "Copia el enlace:" copy_prompt: "Copia el enlace:"
share_regia_text: "Abre este enlace para gestionar el marcador en directo:" share_regia_text: "Abre este enlace para gestionar el marcador en directo:"
share_live_text: "Mira la retransmisión en Match Live TV:" share_live_text: "Mira la retransmisión en Match Live TV:"
min_quality_updated: Calidad mínima actualizada
min_quality_error: Error al actualizar la calidad mínima
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
@@ -600,12 +659,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
+78 -2
View File
@@ -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
@@ -410,16 +432,51 @@ fr:
paused: En pause paused: En pause
live: En direct live: En direct
waiting: En attente waiting: En attente
live_share_label: "Lien direct (spectateurs)" live_share_label: Direct
share_live: Partager le direct share_live: Partager le direct
share_action: Partager
copy_link: Copier le lien copy_link: Copier le lien
regia_share_label: "Lien régie (score)" copy_action: Copier
copy_live: Copier live
copy_regia: Copier régie
links_nav: Actions rapides
regia_share_label: Régie
stream_controls_label: Live
share_regia: Partager le lien régie share_regia: Partager le lien régie
preview_ended: Direct terminé preview_ended: Direct terminé
preview_waiting: Aperçu en attente du signal… preview_waiting: Aperçu en attente du signal…
preview_hide: Masquer l'aperçu
preview_show: Afficher l'aperçu
preview_off_hint: Aperçu désactivé · aucun téléchargement vidéo
pause: Mettre en pause pause: Mettre en pause
resume: Reprendre le direct resume: Reprendre le direct
stop: Terminer le direct stop: Terminer le direct
min_quality:
label: Qualité minimale de transmission
summary: Qualité
hint: L'encodeur ne descend pas en dessous de ce niveau. Automatique adapte le débit pour limiter les coupures.
recommend: "Automatique est conseillé : s'adapte au réseau et au téléphone, sans forcer le maximum."
auto: Automatique
telemetry:
label: Téléphone
waiting: En attente du téléphone…
stale: Téléphone sans réponse
just_now: maintenant
seconds_ago: "il y a %{n}s"
publisher_on: RTMP ok
publisher_off: RTMP absent
battery: Batterie
network: Réseau
bitrate: Débit
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps actuels · max %{target} Mbps"
bitrate_tap: Touchez pour définir la qualité minimale
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Chaude
thermal_serious: Très chaude
thermal_critical: Critique
modal: modal:
set_won_title: Set gagné set_won_title: Set gagné
confirm: Terminer le set confirm: Terminer le set
@@ -464,6 +521,8 @@ fr:
copy_prompt: "Copiez le lien :" copy_prompt: "Copiez le lien :"
share_regia_text: "Ouvrez ce lien pour gérer le score du direct :" share_regia_text: "Ouvrez ce lien pour gérer le score du direct :"
share_live_text: "Regardez le direct sur Match Live TV :" share_live_text: "Regardez le direct sur Match Live TV :"
min_quality_updated: Qualité minimale mise à jour
min_quality_error: Erreur de mise à jour de la qualité minimale
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
@@ -600,12 +659,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
+78 -2
View File
@@ -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
@@ -410,16 +427,51 @@ it:
paused: In pausa paused: In pausa
live: In onda live: In onda
waiting: In attesa waiting: In attesa
live_share_label: "Link diretta (spettatori)" live_share_label: Diretta
share_live: Condividi diretta share_live: Condividi diretta
share_action: Condividi
copy_link: Copia link copy_link: Copia link
regia_share_label: "Link regia (punteggio)" copy_action: Copia
copy_live: Copia live
copy_regia: Copia regia
links_nav: Azioni rapide
regia_share_label: Regia
stream_controls_label: Live
share_regia: Condividi link regia share_regia: Condividi link regia
preview_ended: Diretta terminata preview_ended: Diretta terminata
preview_waiting: Anteprima in attesa del segnale… preview_waiting: Anteprima in attesa del segnale…
preview_hide: Nascondi anteprima
preview_show: Mostra anteprima
preview_off_hint: Anteprima spenta · nessun download video
pause: Metti in pausa pause: Metti in pausa
resume: Riprendi diretta resume: Riprendi diretta
stop: Chiudi diretta stop: Chiudi diretta
min_quality:
label: Qualità minima di trasmissione
summary: Qualità
hint: L'encoder non scende sotto questo livello. Automatica adatta il bitrate per ridurre i salti.
recommend: "Consigliata Automatica: si adatta alla rete e al telefono, senza forzare il massimo."
auto: Automatica
telemetry:
label: Telefono
waiting: In attesa del telefono…
stale: Telefono non risponde
just_now: ora
seconds_ago: "%{n}s fa"
publisher_on: RTMP ok
publisher_off: RTMP assente
battery: Batteria
network: Rete
bitrate: Bitrate
bitrate_max: "max %{n}"
bitrate_title: "%{current} Mbps attuali · massimo %{target} Mbps"
bitrate_tap: Tocca per impostare la qualità minima
fps: FPS
thermal: Temp.
thermal_nominal: OK
thermal_fair: Calda
thermal_serious: Molto calda
thermal_critical: Critica
modal: modal:
set_won_title: Set vinto set_won_title: Set vinto
confirm: Chiudi set confirm: Chiudi set
@@ -464,6 +516,8 @@ it:
copy_prompt: "Copia il link:" copy_prompt: "Copia il link:"
share_regia_text: "Apri questo link per gestire il punteggio della diretta:" share_regia_text: "Apri questo link per gestire il punteggio della diretta:"
share_live_text: "Guarda la diretta su Match Live TV:" share_live_text: "Guarda la diretta su Match Live TV:"
min_quality_updated: Qualità minima aggiornata
min_quality_error: Errore aggiornamento qualità minima
recordings: recordings:
archive: archive:
back_prefix: "←" back_prefix: "←"
@@ -600,12 +654,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
+15
View File
@@ -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
+15
View File
@@ -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
+15
View File
@@ -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
+15
View File
@@ -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é
+15
View File
@@ -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 dellapp 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 dallapp. Se il problema persiste, contatta il supporto indicando dispositivo, versione dellapp 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
+15 -1
View File
@@ -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
+15 -1
View File
@@ -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
+15 -1
View File
@@ -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
+15 -1
View File
@@ -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
+15 -1
View File
@@ -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
View File
@@ -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"
@@ -51,6 +55,7 @@ Rails.application.routes.draw do
post :claim_pairing post :claim_pairing
post :regia_link post :regia_link
post :network_test post :network_test
patch :min_quality
get :youtube_stats get :youtube_stats
end end
end end
@@ -139,6 +144,7 @@ Rails.application.routes.draw do
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/stop", to: "public/regia#stop", as: :public_regia_stop post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
patch "regia/:token/min_quality", to: "public/regia#min_quality", as: :public_regia_min_quality
root to: "public/pages#home" root to: "public/pages#home"
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml } get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
@@ -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,7 @@
# frozen_string_literal: true
class AddMinQualityPresetToStreamSessions < ActiveRecord::Migration[7.2]
def change
add_column :stream_sessions, :min_quality_preset, :string, default: "auto", null: false
end
end
@@ -0,0 +1,5 @@
class AddThermalStateToDeviceStates < ActiveRecord::Migration[7.2]
def change
add_column :device_states, :thermal_state, :string
end
end
+29 -1
View File
@@ -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_13_184100) 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"
@@ -138,6 +138,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.datetime "last_seen_at" t.datetime "last_seen_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "thermal_state"
t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true t.index ["stream_session_id", "device_role"], name: "index_device_states_on_stream_session_id_and_device_role", unique: true
t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id" t.index ["stream_session_id"], name: "index_device_states_on_stream_session_id"
end end
@@ -255,6 +256,29 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id" t.index ["stream_session_id"], name: "index_stream_events_on_stream_session_id"
end end
create_table "stream_nodes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "slug", null: false
t.string "hostname", null: false
t.string "role", default: "home", null: false
t.string "status", default: "ready", null: false
t.string "rtmp_base_url", null: false
t.string "hls_base_url", null: false
t.string "api_base_url", null: false
t.string "internal_rtmp_url"
t.string "internal_hls_url"
t.integer "max_publishers", default: 6, null: false
t.integer "max_relays", default: 6, null: false
t.string "provider", default: "local", null: false
t.string "provider_instance_id"
t.datetime "last_health_at"
t.jsonb "metadata", default: {}, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["role"], name: "index_stream_nodes_on_role"
t.index ["slug"], name: "index_stream_nodes_on_slug", unique: true
t.index ["status"], name: "index_stream_nodes_on_status"
end
create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "stream_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "match_id", null: false t.uuid "match_id", null: false
t.uuid "user_id", null: false t.uuid "user_id", null: false
@@ -280,10 +304,13 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "regia_token_digest" t.string "regia_token_digest"
t.datetime "regia_token_expires_at" t.datetime "regia_token_expires_at"
t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", 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
t.index ["status"], name: "index_stream_sessions_on_status" t.index ["status"], name: "index_stream_sessions_on_status"
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
t.index ["user_id"], name: "index_stream_sessions_on_user_id" t.index ["user_id"], name: "index_stream_sessions_on_user_id"
end end
@@ -411,6 +438,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
add_foreign_key "score_states", "stream_sessions" add_foreign_key "score_states", "stream_sessions"
add_foreign_key "stream_events", "stream_sessions" add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches" add_foreign_key "stream_sessions", "matches"
add_foreign_key "stream_sessions", "stream_nodes"
add_foreign_key "stream_sessions", "users" add_foreign_key "stream_sessions", "users"
add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id" add_foreign_key "subscriptions", "admin_accounts", column: "admin_comped_by_id"
add_foreign_key "subscriptions", "clubs" add_foreign_key "subscriptions", "clubs"
+4 -4
View File
@@ -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
View File
@@ -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;
+277 -41
View File
@@ -4,6 +4,8 @@
--regia-surface: #14141c; --regia-surface: #14141c;
--regia-red: #e53935; --regia-red: #e53935;
--regia-yellow: #f9a825; --regia-yellow: #f9a825;
--regia-green: #43a047;
--regia-orange: #fb8c00;
--regia-text: #f5f5f5; --regia-text: #f5f5f5;
--regia-muted: #9ca3af; --regia-muted: #9ca3af;
--regia-safe-bottom: env(safe-area-inset-bottom, 0px); --regia-safe-bottom: env(safe-area-inset-bottom, 0px);
@@ -22,37 +24,166 @@
.regia-page { .regia-page {
max-width: 480px; max-width: 480px;
margin: 0 auto; margin: 0 auto;
padding: 12px 16px calc(24px + var(--regia-safe-bottom)); padding: 8px 12px calc(12px + var(--regia-safe-bottom));
}
.regia-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.regia-header__text {
min-width: 0;
flex: 1;
} }
.regia-header h1 { .regia-header h1 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
line-height: 1.25;
} }
.regia-header p { .regia-header p {
margin: 4px 0 0; margin: 2px 0 0;
font-size: 0.85rem; font-size: 0.78rem;
color: var(--regia-muted); color: var(--regia-muted);
} }
.regia-badge { .regia-badge {
display: inline-block; display: inline-block;
margin-top: 8px; margin-top: 0;
padding: 4px 10px; padding: 3px 8px;
border-radius: 999px; border-radius: 999px;
font-size: 0.7rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
flex-shrink: 0;
} }
.regia-badge--live { background: #2e7d32; color: #fff; } .regia-badge--live { background: #2e7d32; color: #fff; }
.regia-badge--wait { background: #444; color: #ddd; } .regia-badge--wait { background: #444; color: #ddd; }
.regia-badge--ended { background: #374151; color: #ddd; } .regia-badge--ended { background: #374151; color: #ddd; }
.regia-toolbar {
margin: 8px 0 6px;
display: flex;
align-items: flex-end;
gap: 8px;
}
.regia-tool-group {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
min-width: 0;
}
.regia-tool-group__label {
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-tool-group__btns {
display: flex;
gap: 3px;
}
.regia-tool-group--stream {
margin-left: auto;
padding-left: 8px;
border-left: 1px solid #333;
}
.regia-icon-btn {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border: 1px solid #333;
border-radius: 10px;
background: #16161f;
color: var(--regia-text);
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.regia-icon-btn svg {
width: 20px;
height: 20px;
pointer-events: none;
}
.regia-icon-btn svg.icon-fill {
fill: currentColor;
}
.regia-icon-btn svg.icon-outline {
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.regia-icon-btn .icon-eye-off,
.regia-icon-btn .icon-play {
display: none;
}
.regia-icon-btn.is-off .icon-eye {
display: none;
}
.regia-icon-btn.is-off .icon-eye-off {
display: block;
}
.regia-icon-btn.is-paused .icon-pause {
display: none;
}
.regia-icon-btn.is-paused .icon-play {
display: block;
}
.regia-icon-btn:active { transform: scale(0.96); }
.regia-icon-btn.is-off {
color: var(--regia-yellow);
border-color: #5a4a1a;
}
.regia-icon-btn--danger {
color: var(--regia-red);
border-color: #7a2a28;
}
.regia-preview-wrap {
margin: 6px 0 10px;
}
.regia-preview-wrap.is-off {
margin: 0;
height: 0;
overflow: hidden;
}
.regia-preview { .regia-preview {
margin: 14px 0; margin: 0;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
background: #000; background: #000;
@@ -90,29 +221,29 @@
.regia-scoreboard { .regia-scoreboard {
background: var(--regia-surface); background: var(--regia-surface);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 10px 12px;
margin-bottom: 12px; margin-bottom: 8px;
} }
.regia-sets { .regia-sets {
text-align: center; text-align: center;
font-size: 0.82rem; font-size: 0.78rem;
color: var(--regia-muted); color: var(--regia-muted);
margin: 0 0 8px; margin: 0 0 4px;
} }
.regia-score-line { .regia-score-line {
text-align: center; text-align: center;
font-size: 1.5rem; font-size: 1.2rem;
font-weight: 800; font-weight: 800;
margin: 0; margin: 0;
} }
.regia-partials { .regia-partials {
text-align: center; text-align: center;
font-size: 0.78rem; font-size: 0.75rem;
color: var(--regia-muted); color: var(--regia-muted);
margin: 8px 0 0; margin: 4px 0 0;
} }
.regia-team-row { .regia-team-row {
@@ -120,7 +251,7 @@
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
margin-top: 16px; margin-top: 8px;
} }
.regia-team-name { .regia-team-name {
@@ -131,7 +262,7 @@
} }
.regia-points { .regia-points {
font-size: 2rem; font-size: 1.55rem;
font-weight: 800; font-weight: 800;
text-align: center; text-align: center;
} }
@@ -146,7 +277,7 @@
.regia-btn { .regia-btn {
border: none; border: none;
border-radius: 10px; border-radius: 10px;
padding: 16px 12px; padding: 10px 8px;
font-size: 1rem; font-size: 1rem;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
@@ -156,8 +287,8 @@
.regia-btn:active { transform: scale(0.98); } .regia-btn:active { transform: scale(0.98); }
.regia-btn--point { background: var(--regia-red); color: #fff; } .regia-btn--point { background: var(--regia-red); color: #fff; }
.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; } .regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 8px; }
.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; } .regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 8px; padding: 12px; }
.regia-btn--outline { .regia-btn--outline {
background: transparent; background: transparent;
border: 1px solid #444; border: 1px solid #444;
@@ -173,26 +304,6 @@
margin-top: 8px; margin-top: 8px;
} }
.regia-share-group {
margin: 12px 0;
}
.regia-share-label {
margin: 0 0 6px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--regia-muted);
}
.regia-share {
display: flex;
gap: 8px;
}
.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; }
.regia-toast { .regia-toast {
position: fixed; position: fixed;
bottom: calc(16px + var(--regia-safe-bottom)); bottom: calc(16px + var(--regia-safe-bottom));
@@ -240,7 +351,7 @@
text-align: center; text-align: center;
color: var(--regia-muted); color: var(--regia-muted);
font-size: 0.72rem; font-size: 0.72rem;
padding-top: 24px; padding-top: 8px;
} }
.regia-clock-line { .regia-clock-line {
@@ -255,3 +366,128 @@
gap: 8px; gap: 8px;
margin-top: 12px; margin-top: 12px;
} }
.regia-quality {
margin: 6px 0 0;
padding: 8px 10px 10px;
background: var(--regia-surface);
border-radius: 10px;
}
.regia-quality[hidden] { display: none !important; }
.regia-quality__hint {
margin: 0 0 8px;
font-size: 0.68rem;
line-height: 1.35;
color: var(--regia-muted);
}
.regia-quality label {
display: block;
font-size: 0.68rem;
color: var(--regia-muted);
margin: 0 0 4px;
}
.regia-quality select {
width: 100%;
background: #0a0a0e;
color: var(--regia-text);
border: 1px solid #2a2a35;
border-radius: 8px;
padding: 8px 10px;
font-size: 0.82rem;
}
.regia-telemetry {
margin-top: 8px;
}
.regia-telemetry__row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.regia-chip {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
text-align: center;
padding: 6px 2px 5px;
border-radius: 8px;
background: var(--regia-surface);
color: var(--regia-text);
border: 1px solid transparent;
}
.regia-chip--action {
appearance: none;
-webkit-appearance: none;
font: inherit;
cursor: pointer;
width: 100%;
}
.regia-chip--action:focus-visible {
outline: 2px solid var(--regia-yellow);
outline-offset: 1px;
}
.regia-chip--action.is-open {
border-color: #3a3a48;
}
.regia-chip svg {
width: 14px;
height: 14px;
fill: currentColor;
}
.regia-chip__label {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__value {
font-size: 0.72rem;
font-weight: 800;
line-height: 1.15;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.regia-chip__hint {
font-size: 0.55rem;
font-weight: 600;
color: var(--regia-muted);
line-height: 1;
white-space: nowrap;
}
.regia-chip__hint[hidden] { display: none; }
.regia-chip.is-ok { color: var(--regia-green); }
.regia-chip.is-warn { color: var(--regia-orange); }
.regia-chip.is-bad { color: var(--regia-red); }
.regia-telemetry__meta {
margin: 4px 0 0;
font-size: 0.65rem;
color: var(--regia-muted);
text-align: center;
}
.regia-telemetry.is-stale .regia-telemetry__meta {
color: var(--regia-yellow);
}
+262 -5
View File
@@ -37,13 +37,32 @@
shareLiveText: "Guarda la diretta su Match Live TV:", shareLiveText: "Guarda la diretta su Match Live TV:",
previewWaiting: "Anteprima in attesa del segnale…", previewWaiting: "Anteprima in attesa del segnale…",
streamEnded: "Diretta terminata", streamEnded: "Diretta terminata",
previewHide: "Nascondi anteprima",
previewShow: "Mostra anteprima",
previewOffHint: "Anteprima spenta · nessun download video",
resumeLabel: "Riprendi diretta", resumeLabel: "Riprendi diretta",
pauseLabel: "Metti in pausa", pauseLabel: "Metti in pausa",
endedBadge: "Terminata", endedBadge: "Terminata",
pausedBadge: "In pausa", pausedBadge: "In pausa",
liveBadge: "In onda", liveBadge: "In onda",
waitingBadge: "In attesa", waitingBadge: "In attesa",
subtitlePrefix: "Regia ·" subtitlePrefix: "Regia ·",
minQualityUpdated: "Qualità minima aggiornata",
minQualityError: "Errore aggiornamento qualità minima",
minQualityAuto: "Automatica",
telemetryWaiting: "In attesa del telefono…",
telemetryStale: "Telefono non risponde",
telemetryJustNow: "ora",
telemetrySecondsAgo: "%{n}s fa",
telemetryPublisherOn: "RTMP ok",
telemetryPublisherOff: "RTMP assente",
telemetryThermalNominal: "OK",
telemetryThermalFair: "Calda",
telemetryThermalSerious: "Calda",
telemetryThermalCritical: "Critica",
telemetryBitrateMax: "max %{n}",
telemetryBitrateTitle: "%{current} Mbps attuali · massimo %{target} Mbps",
telemetryBitrateTap: "Tocca per impostare la qualità minima"
}; };
function loadI18n() { function loadI18n() {
@@ -77,6 +96,7 @@
pauseUrl: root.dataset.pauseUrl, pauseUrl: root.dataset.pauseUrl,
resumeUrl: root.dataset.resumeUrl, resumeUrl: root.dataset.resumeUrl,
stopUrl: root.dataset.stopUrl, stopUrl: root.dataset.stopUrl,
minQualityUrl: root.dataset.minQualityUrl,
cableUrl: root.dataset.cableUrl, cableUrl: root.dataset.cableUrl,
hlsUrl: root.dataset.hlsUrl, hlsUrl: root.dataset.hlsUrl,
homeName: root.dataset.homeName, homeName: root.dataset.homeName,
@@ -102,15 +122,28 @@
modalCancel: document.getElementById("modal-cancel"), modalCancel: document.getElementById("modal-cancel"),
preview: document.getElementById("regia-preview"), preview: document.getElementById("regia-preview"),
previewPlaceholder: document.getElementById("regia-preview-placeholder"), previewPlaceholder: document.getElementById("regia-preview-placeholder"),
previewWrap: document.getElementById("regia-preview-wrap"),
btnTogglePreview: document.getElementById("btn-toggle-preview"),
btnPause: document.getElementById("btn-pause"), btnPause: document.getElementById("btn-pause"),
subtitle: document.getElementById("regia-subtitle") minQuality: document.getElementById("min-quality"),
subtitle: document.getElementById("regia-subtitle"),
telemetry: document.getElementById("regia-telemetry"),
telBattery: document.getElementById("tel-battery"),
telNetwork: document.getElementById("tel-network"),
telBitrate: document.getElementById("tel-bitrate"),
telFps: document.getElementById("tel-fps"),
telThermal: document.getElementById("tel-thermal"),
telMeta: document.getElementById("tel-meta"),
qualityPanel: document.getElementById("regia-quality")
}; };
let pendingAction = null; let pendingAction = null;
let hls = null; let hls = null;
let previewStarted = false; let previewStarted = false;
let previewWanted = true;
let wasPausedPreview = false; let wasPausedPreview = false;
let streamPaused = root.dataset.initialPaused === "true"; let streamPaused = root.dataset.initialPaused === "true";
const PREVIEW_PREF_KEY = "mltv-regia-preview";
function hlsUrlFresh() { function hlsUrlFresh() {
const sep = cfg.hlsUrl.includes("?") ? "&" : "?"; const sep = cfg.hlsUrl.includes("?") ? "&" : "?";
@@ -139,8 +172,43 @@
} }
} }
function readPreviewPref() {
try {
return localStorage.getItem(PREVIEW_PREF_KEY) !== "0";
} catch (_) {
return true;
}
}
function writePreviewPref(on) {
try {
localStorage.setItem(PREVIEW_PREF_KEY, on ? "1" : "0");
} catch (_) {}
}
function syncPreviewToggleUi() {
if (!els.previewWrap || !els.btnTogglePreview) return;
const label = previewWanted ? I18N.previewHide : I18N.previewShow;
els.previewWrap.classList.toggle("is-off", !previewWanted);
els.btnTogglePreview.classList.toggle("is-off", !previewWanted);
els.btnTogglePreview.setAttribute("aria-pressed", previewWanted ? "true" : "false");
els.btnTogglePreview.setAttribute("aria-label", label);
els.btnTogglePreview.title = previewWanted ? I18N.previewHide : I18N.previewOffHint;
}
function setPreviewWanted(on) {
previewWanted = !!on;
writePreviewPref(previewWanted);
syncPreviewToggleUi();
if (previewWanted) {
startPreview();
} else {
destroyPreview();
}
}
function refreshPreviewForCover() { function refreshPreviewForCover() {
if (!els.preview || !cfg.hlsUrl || cfg.streamClosed) return; if (!els.preview || !cfg.hlsUrl || cfg.streamClosed || !previewWanted) return;
destroyPreview(); destroyPreview();
previewStarted = false; previewStarted = false;
startPreview(); startPreview();
@@ -243,7 +311,10 @@
if (!els.btnPause) return; if (!els.btnPause) return;
const paused = !!(data.paused || data.status === "paused"); const paused = !!(data.paused || data.status === "paused");
streamPaused = paused; streamPaused = paused;
els.btnPause.textContent = paused ? I18N.resumeLabel : I18N.pauseLabel; const label = paused ? I18N.resumeLabel : I18N.pauseLabel;
els.btnPause.classList.toggle("is-paused", paused);
els.btnPause.setAttribute("aria-label", label);
els.btnPause.title = label;
} }
function setBadge(data) { function setBadge(data) {
@@ -364,6 +435,11 @@
} }
const paused = data.paused || data.status === "paused"; const paused = data.paused || data.status === "paused";
if (!previewWanted) {
destroyPreview();
wasPausedPreview = paused;
return;
}
if (data.on_air) { if (data.on_air) {
if (!previewStarted) { if (!previewStarted) {
startPreview(); startPreview();
@@ -382,6 +458,119 @@
wasPausedPreview = paused; wasPausedPreview = paused;
} }
function mbpsNumber(bps) {
const n = Number(bps);
if (!n || n <= 0) return null;
const mbps = n >= 10_000 ? n / 1e6 : n / 1e3;
return mbps >= 10 ? mbps.toFixed(0) : mbps.toFixed(1);
}
function formatAge(iso) {
if (!iso) return null;
const then = Date.parse(iso);
if (!then) return null;
const secs = Math.max(0, Math.round((Date.now() - then) / 1000));
if (secs < 8) return I18N.telemetryJustNow;
return fillTemplate(I18N.telemetrySecondsAgo, { n: secs });
}
function setChip(el, text, level, hint) {
if (!el) return;
const valueEl = el.querySelector(".regia-chip__value") || el;
valueEl.textContent = text;
el.classList.toggle("is-ok", level === "ok");
el.classList.toggle("is-warn", level === "warn");
el.classList.toggle("is-bad", level === "bad");
const hintEl = el.querySelector(".regia-chip__hint");
if (hintEl) {
hintEl.textContent = hint || "";
hintEl.hidden = !hint;
}
}
function formatThermal(state) {
const key = String(state || "").toLowerCase();
if (key === "nominal") return { text: I18N.telemetryThermalNominal, level: "ok" };
if (key === "fair") return { text: I18N.telemetryThermalFair, level: "warn" };
if (key === "serious") return { text: I18N.telemetryThermalSerious, level: "bad" };
if (key === "critical") return { text: I18N.telemetryThermalCritical, level: "bad" };
return { text: "—", level: null };
}
function bitrateLevel(current, target, paused) {
if (paused || !current || current <= 0) return null;
if (target && target > 0) {
const ratio = current / target;
if (ratio >= 0.7) return "ok";
if (ratio >= 0.4) return "warn";
return "bad";
}
const mbps = current >= 10_000 ? current / 1e6 : current / 1e3;
if (mbps >= 2.5) return "ok";
if (mbps >= 1.5) return "warn";
return "bad";
}
function bitrateTitle(currentMbps, targetMbps) {
const tap = I18N.telemetryBitrateTap;
if (currentMbps && targetMbps) {
return `${fillTemplate(I18N.telemetryBitrateTitle, { current: currentMbps, target: targetMbps })} · ${tap}`;
}
if (currentMbps) return `${currentMbps} Mbps · ${tap}`;
return tap;
}
function syncTelemetry(data) {
if (!els.telemetry) return;
const tel = data.telemetry || {};
const battery = tel.battery;
const fps = tel.fps;
const bitrate = tel.bitrate;
const target = tel.target_bitrate || tel.targetBitrate;
const lastSeen = tel.last_seen_at || tel.lastSeenAt;
const ageSecs = lastSeen ? Math.max(0, Math.round((Date.now() - Date.parse(lastSeen)) / 1000)) : null;
const stale = ageSecs == null || ageSecs > 25;
const publishing = !!tel.publisher_online || !!tel.publisherOnline;
let battLevel = null;
let battText = "—";
if (battery != null && battery !== "") {
battText = `${battery}%`;
if (battery <= 15) battLevel = "bad";
else if (battery <= 25) battLevel = "warn";
}
setChip(els.telBattery, battText, battLevel);
setChip(els.telNetwork, tel.network || "—");
const currentMbps = mbpsNumber(bitrate);
const targetMbps = mbpsNumber(target);
const rateText = currentMbps || "—";
const rateHint = currentMbps && targetMbps
? fillTemplate(I18N.telemetryBitrateMax, { n: targetMbps })
: null;
if (els.telBitrate) els.telBitrate.title = bitrateTitle(currentMbps, targetMbps);
setChip(els.telBitrate, rateText, bitrateLevel(Number(bitrate), Number(target), data.paused), rateHint);
let fpsLevel = null;
if (fps != null && fps !== "" && Number(fps) > 0 && Number(fps) < 20) fpsLevel = "warn";
setChip(els.telFps, fps && Number(fps) > 0 ? String(fps) : "—", fpsLevel);
const thermal = formatThermal(tel.thermal_state || tel.thermalState);
setChip(els.telThermal, thermal.text, thermal.level);
els.telemetry.classList.toggle("is-stale", stale && !data.paused && !data.stream_closed);
if (!els.telMeta) return;
if (stale && !lastSeen) {
els.telMeta.textContent = I18N.telemetryWaiting;
return;
}
const parts = [publishing ? I18N.telemetryPublisherOn : I18N.telemetryPublisherOff];
const age = formatAge(lastSeen);
if (age) parts.push(age);
if (stale && !data.paused && !data.stream_closed) parts.push(I18N.telemetryStale);
els.telMeta.textContent = parts.join(" · ");
}
async function pollStatus() { async function pollStatus() {
try { try {
const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } }); const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } });
@@ -389,6 +578,8 @@
const data = await res.json(); const data = await res.json();
applyScorePayload(data); applyScorePayload(data);
setBadge(data); setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data); syncPreviewFromStatus(data);
} catch (_) {} } catch (_) {}
} }
@@ -401,7 +592,7 @@
} }
function startPreview() { function startPreview() {
if (cfg.streamClosed || !els.preview || !cfg.hlsUrl || previewStarted) return; if (cfg.streamClosed || !previewWanted || !els.preview || !cfg.hlsUrl || previewStarted) return;
previewStarted = true; previewStarted = true;
const url = hlsUrlFresh(); const url = hlsUrlFresh();
@@ -454,6 +645,8 @@
const wasPaused = streamPaused; const wasPaused = streamPaused;
applyScorePayload(data); applyScorePayload(data);
setBadge(data); setBadge(data);
syncMinQuality(data);
syncTelemetry(data);
syncPreviewFromStatus(data); syncPreviewFromStatus(data);
toast(wasPaused ? I18N.resumed : I18N.pausedCover); toast(wasPaused ? I18N.resumed : I18N.pausedCover);
} }
@@ -473,6 +666,70 @@
els.btnPause?.addEventListener("click", () => togglePauseStream().catch((e) => toast(e.message))); els.btnPause?.addEventListener("click", () => togglePauseStream().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)));
let minQualityBusy = false;
function setQualityPanelOpen(open) {
if (!els.qualityPanel || !els.telBitrate) return;
els.qualityPanel.hidden = !open;
els.telBitrate.setAttribute("aria-expanded", open ? "true" : "false");
els.telBitrate.classList.toggle("is-open", open);
if (open && els.minQuality) {
window.requestAnimationFrame(() => els.minQuality.focus());
}
}
function syncMinQuality(data) {
if (!els.minQuality || minQualityBusy) return;
const value = data.min_quality_preset || "auto";
if (els.minQuality.value !== value) els.minQuality.value = value;
}
async function setMinQuality(preset) {
if (!cfg.minQualityUrl) return;
minQualityBusy = true;
try {
const res = await fetch(cfg.minQualityUrl, {
method: "PATCH",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ min_quality_preset: preset })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || I18N.minQualityError);
minQualityBusy = false;
syncMinQuality(data);
toast(I18N.minQualityUpdated);
} catch (e) {
toast(e.message || I18N.minQualityError);
} finally {
minQualityBusy = false;
}
}
els.minQuality?.addEventListener("change", () => {
setMinQuality(els.minQuality.value);
});
els.telBitrate?.addEventListener("click", (e) => {
e.stopPropagation();
const opening = !!(els.qualityPanel && els.qualityPanel.hidden);
setQualityPanelOpen(opening);
});
document.addEventListener("click", (e) => {
if (!els.qualityPanel || els.qualityPanel.hidden) return;
if (els.qualityPanel.contains(e.target) || els.telBitrate?.contains(e.target)) return;
setQualityPanelOpen(false);
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") setQualityPanelOpen(false);
});
els.btnTogglePreview?.addEventListener("click", () => setPreviewWanted(!previewWanted));
previewWanted = readPreviewPref();
syncPreviewToggleUi();
if (!previewWanted) destroyPreview();
async function shareLink(url, title, text) { async function shareLink(url, title, text) {
if (!url) { if (!url) {
toast(I18N.linkUnavailable); toast(I18N.linkUnavailable);
@@ -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
+127
View File
@@ -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
+2 -2
View File
@@ -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
+47 -1
View File
@@ -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") }
@@ -12,7 +12,18 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token) get public_regia_path(token)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("Condividi diretta") expect(response.body).to include("Condividi diretta")
expect(response.body).to include("Copia live")
expect(response.body).to include("Condividi link regia") expect(response.body).to include("Condividi link regia")
expect(response.body).to include("Copia regia")
expect(response.body).to include(">Diretta<")
expect(response.body).to include(">Regia<")
expect(response.body).to include("regia-tool-group")
expect(response.body).to include("Nascondi anteprima")
expect(response.body).to include('id="btn-pause"')
expect(response.body).to include('id="btn-stop"')
expect(response.body).to include("Qualità minima di trasmissione")
expect(response.body).to include('id="tel-bitrate"')
expect(response.body).to include('aria-controls="regia-quality"')
expect(response.body).to include("/live/#{session.id}") expect(response.body).to include("/live/#{session.id}")
expect(response.body).not_to include("Aruba") expect(response.body).not_to include("Aruba")
end end
@@ -156,4 +167,39 @@ RSpec.describe "Public regia", type: :request do
get public_regia_path(token) get public_regia_path(token)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
end end
it "aggiorna la qualità minima di trasmissione" do
token = Sessions::RegiaAccess.new(session).issue_token!
patch public_regia_min_quality_path(token), params: { min_quality_preset: "720p_30_1.5mbps" }
expect(response).to have_http_status(:ok)
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
json = JSON.parse(response.body)
expect(json["min_quality_preset"]).to eq("720p_30_1.5mbps")
end
it "espone la telemetria della camera nello status" do
session.device_states.create!(
device_role: "camera",
battery_level: 81,
network_type: "WiFi",
current_bitrate: 2_400_000,
target_bitrate: 4_500_000,
fps: 30,
thermal_state: "nominal",
last_seen_at: Time.current
)
token = Sessions::RegiaAccess.new(session).issue_token!
get public_regia_path(token)
expect(response.body).to include('id="regia-telemetry"')
get public_regia_status_path(token)
expect(response).to have_http_status(:ok)
tel = response.parsed_body["telemetry"]
expect(tel["battery"]).to eq(81)
expect(tel["network"]).to eq("WiFi")
expect(tel["fps"]).to eq(30)
expect(tel["bitrate"]).to eq(2_400_000)
expect(tel["thermal_state"]).to eq("nominal")
expect(tel).to have_key("publisher_online")
end
end end
@@ -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
@@ -11,4 +11,11 @@ RSpec.describe Sessions::SelectQuality do
expect(preset[:id]).to eq("1080p_30_4.5mbps") expect(preset[:id]).to eq("1080p_30_4.5mbps")
end end
it "offre 1080p come qualità minima solo se la sessione è 1080p" do
expect(described_class.allowed_min_ids("720p_30_2.5mbps")).to eq(
%w[auto 720p_30_1.5mbps 720p_30_2.5mbps 720p_30_4mbps]
)
expect(described_class.allowed_min_ids("1080p_30_4.5mbps")).to include("1080p_30_4.5mbps")
end
end end
@@ -0,0 +1,41 @@
require "rails_helper"
RSpec.describe Sessions::SetMinQuality do
let!(:user) { User.create!(email: "abr@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) do
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "live",
quality_preset: "720p_30_2.5mbps"
)
end
it "salva il pavimento e notifica i client" do
expect(SessionChannel).to receive(:broadcast_message).with(
session,
hash_including(action: "set_min_quality", min_quality_preset: "720p_30_1.5mbps")
)
described_class.new(session, preset: "720p_30_1.5mbps").call
expect(session.reload.min_quality_preset).to eq("720p_30_1.5mbps")
expect(session.stream_events.where(event_type: "quality_changed")).to exist
end
it "rifiuta 1080p se la sessione è 720p" do
expect {
described_class.new(session, preset: "1080p_30_4.5mbps").call
}.to raise_error(ArgumentError)
end
it "accetta auto come qualità minima" do
session.update!(min_quality_preset: "720p_30_1.5mbps")
described_class.new(session, preset: "auto").call
expect(session.reload.min_quality_preset).to eq("auto")
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
+41
View File
@@ -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à dellapp)
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.
+6 -6
View File
@@ -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 ~2090s il server avvia overlay (tee RTMPS) e attiva la broadcast quando ingest è `active`. | | YouTube «In programma» / copertina | Entro ~2090s il server avvia il relay RTMPS e attiva la broadcast quando lingest è 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. |
+25 -19
View File
@@ -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,12 @@ 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, HLS, ruolo ffmpeg |
| [`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 +416,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-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) |
| 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 |
+7 -7
View File
@@ -1,12 +1,12 @@
# Gap Android → iOS: allineamento app nativa # Gap Android → iOS: allineamento app nativa
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare unapp **allineata ad Android `2.0.5-native`**. Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare unapp **allineata ad Android**.
**Aggiornato:** 24 luglio 2026 **Aggiornato:** 8 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. 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 +59,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 +141,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`).
--- ---
+88
View File
@@ -0,0 +1,88 @@
# iOS — allineamento password policy + errori API localizzati
Documento operativo per **Cursor su Mac**: allineare lapp 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 lapp 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`.
+47 -32
View File
@@ -1,23 +1,25 @@
# Diretta live — architettura MediaMTX # Diretta live — architettura MediaMTX
## Idea (non è folle) **Ultimo aggiornamento:** 2026-07-29
## 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** lHLS verso il sito. 2. **Pausa / rete assente**`alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** lHLS verso il sito.
3. **YouTube** `ffmpeg` in container Rails/Sidekiq legge **sempre** quelluscita (`-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`; lAAC 48 kHz mono è già prodotto dallapp / 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 luscita HLS). 3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere luscita 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,27 +77,32 @@ 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 dellRTMP (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** (lapp 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 delloverlay (era il carico principale).
### Punteggio (persistenza) ### Punteggio (persistenza)
Lapp mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay. Lapp mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `score_action`) → `Scoring::SyncState`, poi aggiorna loverlay 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`.
+1 -1
View File
@@ -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
+5 -1
View File
@@ -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):** loverlay video è composito **solo sullapp 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)
Sullapp nativa loverlay è composito in GPU senza ricodifica aggiuntiva: Sullapp nativa loverlay è composito in GPU **prima** dellencode 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 |
+17 -41
View File
@@ -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 dallapp (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 dallapp
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 lapp pubblica RTMP con overlay)
## Se qualcosa non va ## Se qualcosa non va
| Sintomo | Cosa fare | | Sintomo | Cosa fare |
|---------|-----------| |---------|-----------|
| «YouTube temporaneamente occupato» | Attendi 1520 min (rate limit Google). **Non** creare molte sessioni di fila. | | «YouTube temporaneamente occupato» | Attendi 1520 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 dalloverlay 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
+2 -2
View File
@@ -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 dallapp 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 |
|-----------------------|-------------------|
| 14 | Comodo |
| 58 | 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 lingest 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 lapp; 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 luplink 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:
- luplink 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 luscita 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 **3090+ 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 (bassomedio, 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 allinizio)
- `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. cores1).
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 sullowner** (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 >1530 min **e**
- fuori dalla finestra “weekend caldo” (opzionale)
**Warm pool:** sabato 8:00 → min 12 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 loverflow è 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.150.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 |
| 1015 | soft cap 46 | 1× VM 48 vCPU | Warm consigliato in giornata evento |
| 2030 | soft cap 46 | 24× 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 (12 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: 812 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 allowner
- [ ] 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 lencoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
- Dedicated più grosso come baseline (spesso più economico delloverflow cronico ogni weekend)
---
## 9. Runbook di reazione (anche prima dellautoscaler)
### 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 &lt;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 lidea 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 loverflow è sufficiente o se serve anche più ingest/baseline hardware.
)
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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)
+3 -1
View File
@@ -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
+5 -2
View File
@@ -59,6 +59,8 @@ services:
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
MEDIAMTX_API_URL: http://mediamtx:9997 MEDIAMTX_API_URL: http://mediamtx:9997
MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935} MEDIAMTX_RTMP_URL: ${MEDIAMTX_RTMP_URL:-rtmp://127.0.0.1:1935}
MEDIAMTX_HLS_URL: ${MEDIAMTX_HLS_URL:-http://mediamtx:8888}
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:3000/hls}
MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret} MEDIAMTX_WEBHOOK_SECRET: ${MEDIAMTX_WEBHOOK_SECRET:-mediamtx_webhook_dev_secret}
RAILS_WEBHOOK_URL: http://rails:3000 RAILS_WEBHOOK_URL: http://rails:3000
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min} SECRET_KEY_BASE: ${SECRET_KEY_BASE:-dev_secret_key_base_change_me_32chars_min}
@@ -70,8 +72,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:-}

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