Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83a804a709 | ||
|
|
b8694a6b7b | ||
|
|
5daada81b0 | ||
|
|
d85bab30d1 | ||
|
|
7b3256c8a0 | ||
|
|
9bc89fceba | ||
|
|
d5ec266437 | ||
|
|
a448ad59ee | ||
|
|
74a156cedc | ||
|
|
97d787f758 |
@@ -0,0 +1,57 @@
|
||||
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: "Name is 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: "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 "Current password is incorrect"
|
||||
when :too_short then "Password must be at least 8 characters"
|
||||
when :mismatch then "Passwords do not match"
|
||||
else "Unable to update password"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
module Api
|
||||
module V1
|
||||
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
|
||||
user = User.new(
|
||||
@@ -42,6 +42,13 @@ module Api
|
||||
render json: user_json(current_user)
|
||||
end
|
||||
|
||||
def forgot_password
|
||||
Users::RequestPasswordReset.call(email: params[:email])
|
||||
render json: {
|
||||
message: "If the email is registered, you will receive a password reset link shortly."
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def token_response(user)
|
||||
|
||||
@@ -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?
|
||||
flash.now[:alert] = t("flash.accounts.password_#{result.error}")
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,11 +4,7 @@ module Public
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:email]&.downcase&.strip)
|
||||
if user
|
||||
token = user.generate_password_reset!
|
||||
UserMailer.password_reset(user, token).deliver_now
|
||||
end
|
||||
Users::RequestPasswordReset.call(email: params[:email])
|
||||
|
||||
redirect_to public_login_path,
|
||||
notice: t("flash.password_resets.email_sent")
|
||||
|
||||
@@ -156,7 +156,7 @@ module Ops
|
||||
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
||||
)
|
||||
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
|
||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||
end
|
||||
|
||||
@@ -116,31 +116,30 @@ module Streams
|
||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||
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)
|
||||
mode, url = intake_source
|
||||
common_head = [
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt"
|
||||
]
|
||||
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
|
||||
|
||||
if mode == :rtmp
|
||||
return [
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
return common_head + [
|
||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||
"-rw_timeout", "15000000",
|
||||
"-i", url,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
"-bsf:a", "aac_adtstoasc",
|
||||
"-f", "flv", output
|
||||
]
|
||||
"-i", url
|
||||
] + copy_tail
|
||||
end
|
||||
|
||||
[
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
common_head + [
|
||||
"-rw_timeout", "15000000",
|
||||
"-live_start_index", "-1",
|
||||
"-i", url,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
"-c:a", "copy",
|
||||
"-bsf:a", "aac_adtstoasc",
|
||||
"-f", "flv", output
|
||||
]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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.blank? || @password.length < 8
|
||||
return Result.new(ok?: false, error: :too_short)
|
||||
end
|
||||
|
||||
if @password != @password_confirmation
|
||||
return Result.new(ok?: false, error: :mismatch)
|
||||
end
|
||||
|
||||
@user.update!(password: @password)
|
||||
@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
|
||||
@@ -41,6 +41,7 @@
|
||||
<% elsif current_user.manageable_teams.any? %>
|
||||
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
||||
<% 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" %>
|
||||
<% else %>
|
||||
· <%= link_to t("nav.login"), public_login_path %>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<% 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>
|
||||
<%= 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? %>
|
||||
(<%= @subscription.admin_comped_reason %>)
|
||||
<% 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>
|
||||
</div>
|
||||
<% else %>
|
||||
|
||||
@@ -40,6 +40,6 @@
|
||||
<div class="card" style="margin-top:32px;text-align:center">
|
||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||
<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>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||
<% 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" %>
|
||||
<% else %>
|
||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||
|
||||
@@ -78,7 +78,7 @@ module MatchLiveTv
|
||||
end
|
||||
|
||||
def privacy_controller_email
|
||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
|
||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||
end
|
||||
|
||||
def privacy_controller_address
|
||||
|
||||
@@ -606,6 +606,13 @@ de:
|
||||
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
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_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert.
|
||||
replay:
|
||||
download_unavailable: Download nicht verfügbar
|
||||
not_available: Replay nicht verfügbar
|
||||
|
||||
@@ -606,6 +606,13 @@ en:
|
||||
password_min_length: Password must be at least 8 characters
|
||||
password_mismatch: Passwords don't match
|
||||
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_mismatch: Passwords do not match
|
||||
password_updated: Password updated.
|
||||
replay:
|
||||
download_unavailable: Download not available
|
||||
not_available: Replay not available
|
||||
|
||||
@@ -606,6 +606,13 @@ es:
|
||||
password_min_length: La contraseña debe tener al menos 8 caracteres
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
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_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada.
|
||||
replay:
|
||||
download_unavailable: Descarga no disponible
|
||||
not_available: Repetición no disponible
|
||||
|
||||
@@ -606,6 +606,13 @@ fr:
|
||||
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
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_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour.
|
||||
replay:
|
||||
download_unavailable: Téléchargement non disponible
|
||||
not_available: Replay non disponible
|
||||
|
||||
@@ -606,6 +606,13 @@ it:
|
||||
password_min_length: La password deve avere almeno 8 caratteri
|
||||
password_mismatch: Le password non coincidono
|
||||
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_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata.
|
||||
replay:
|
||||
download_unavailable: Download non disponibile
|
||||
not_available: Replay non disponibile
|
||||
|
||||
@@ -16,6 +16,7 @@ de:
|
||||
my_team: Mein Team
|
||||
login: Anmelden
|
||||
logout: Abmelden
|
||||
account: Konto
|
||||
signup: Team registrieren
|
||||
footer:
|
||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||
@@ -118,6 +119,18 @@ de:
|
||||
signup_link: registrieren Sie sich
|
||||
mobile_app_label: "Mobile App:"
|
||||
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 Zeichen)"
|
||||
save_profile: Profil speichern
|
||||
save_password: Passwort aktualisieren
|
||||
common:
|
||||
privacy: Datenschutz
|
||||
cookies: Cookies
|
||||
|
||||
@@ -16,6 +16,7 @@ en:
|
||||
my_team: My team
|
||||
login: Log in
|
||||
logout: Log out
|
||||
account: Account
|
||||
signup: Register a team
|
||||
footer:
|
||||
tagline: Every match, every event, for those who can't be there
|
||||
@@ -118,6 +119,18 @@ en:
|
||||
signup_link: sign up
|
||||
mobile_app_label: "Mobile 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 characters)"
|
||||
save_profile: Save profile
|
||||
save_password: Update password
|
||||
common:
|
||||
privacy: Privacy
|
||||
cookies: Cookies
|
||||
|
||||
@@ -16,6 +16,7 @@ es:
|
||||
my_team: Mi equipo
|
||||
login: Acceder
|
||||
logout: Salir
|
||||
account: Cuenta
|
||||
signup: Registrar equipo
|
||||
footer:
|
||||
tagline: Cada partido, cada evento, para quien no puede estar
|
||||
@@ -118,6 +119,18 @@ es:
|
||||
signup_link: regístrate
|
||||
mobile_app_label: "App móvil:"
|
||||
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 caracteres)"
|
||||
save_profile: Guardar perfil
|
||||
save_password: Actualizar contraseña
|
||||
common:
|
||||
privacy: Privacidad
|
||||
cookies: Cookies
|
||||
|
||||
@@ -16,6 +16,7 @@ fr:
|
||||
my_team: Mon équipe
|
||||
login: Connexion
|
||||
logout: Déconnexion
|
||||
account: Compte
|
||||
signup: Inscrire une équipe
|
||||
footer:
|
||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||
@@ -118,6 +119,18 @@ fr:
|
||||
signup_link: inscrivez-vous
|
||||
mobile_app_label: "Application mobile :"
|
||||
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 caractères)"
|
||||
save_profile: Enregistrer le profil
|
||||
save_password: Mettre à jour le mot de passe
|
||||
common:
|
||||
privacy: Confidentialité
|
||||
cookies: Cookies
|
||||
|
||||
@@ -16,6 +16,7 @@ it:
|
||||
my_team: La mia squadra
|
||||
login: Accedi
|
||||
logout: Esci
|
||||
account: Account
|
||||
signup: Registra squadra
|
||||
footer:
|
||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||
@@ -118,6 +119,18 @@ it:
|
||||
signup_link: registrati
|
||||
mobile_app_label: "App mobile:"
|
||||
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 caratteri)"
|
||||
save_profile: Salva profilo
|
||||
save_password: Aggiorna password
|
||||
common:
|
||||
privacy: Privacy
|
||||
cookies: Cookie
|
||||
|
||||
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
|
||||
post "auth/logout", to: "auth#logout"
|
||||
post "auth/refresh", to: "auth#refresh"
|
||||
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 "invitations/:token", to: "invitations#show"
|
||||
@@ -163,6 +167,9 @@ Rails.application.routes.draw do
|
||||
post "password/forgot", to: "password_resets#create"
|
||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||
patch "password/reset", to: "password_resets#update"
|
||||
get "account", to: "accounts#show", as: :account
|
||||
patch "account", to: "accounts#update"
|
||||
patch "account/password", to: "accounts#update_password", as: :account_password
|
||||
get "clubs/new", to: "clubs#new", as: :new_club
|
||||
post "clubs", to: "clubs#create"
|
||||
get "clubs/:id", to: "clubs#show", as: :club
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
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
|
||||
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
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Streams::YoutubeRelay do
|
||||
describe ".youtube_ffmpeg_args (private)" do
|
||||
def args(mode, url)
|
||||
described_class.send(:youtube_ffmpeg_args, [mode, url], "rtmps://a.rtmps.youtube.com/live2/key")
|
||||
end
|
||||
|
||||
it "remuxes RTMP with video and audio copy (no AAC re-encode)" do
|
||||
cmd = args(:rtmp, "rtmp://mediamtx:1935/live/match_x")
|
||||
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-f", "flv")
|
||||
expect(cmd).not_to include("aac")
|
||||
expect(cmd.join(" ")).not_to include("-b:a")
|
||||
end
|
||||
|
||||
it "remuxes HLS with AAC bitstream filter for FLV" do
|
||||
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
|
||||
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
|
||||
expect(cmd).not_to include("-b:a")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# Android App Links / Digital Asset Links
|
||||
|
||||
Play Console verifica `https://www.matchlivetv.it/.well-known/assetlinks.json`.
|
||||
|
||||
## File
|
||||
|
||||
- Servito da **edge** (nginx): `infra/nginx-edge/well-known/assetlinks.json`
|
||||
- Specchio in Rails public: `backend/public/.well-known/assetlinks.json`
|
||||
|
||||
Package: `com.matchlivetv.match_live_tv`
|
||||
Path App Links: `https://www.matchlivetv.it/join…` (`pathPrefix="/join"`)
|
||||
|
||||
Nel JSON ci sono:
|
||||
|
||||
1. SHA-256 del **upload key** (`native/android/keystore/matchlivetv-upload.jks`)
|
||||
2. SHA-256 del certificato **App signing** di Play (da Play Console → Integrità dell’app)
|
||||
|
||||
Relazioni richieste da Play per la condivisione credenziali:
|
||||
|
||||
- `delegate_permission/common.handle_all_urls`
|
||||
- `delegate_permission/common.get_login_creds`
|
||||
|
||||
Se Play rigenera il JSON, sostituisci `infra/nginx-edge/well-known/assetlinks.json` (e lo specchio in `backend/public/.well-known/`) e ricarica edge.
|
||||
## Deploy edge (senza rebuild Rails)
|
||||
|
||||
Sul server:
|
||||
|
||||
```bash
|
||||
cd /opt/matchlivetv/infra
|
||||
# dopo git pull del repo
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d edge
|
||||
curl -sS -D- https://www.matchlivetv.it/.well-known/assetlinks.json | head
|
||||
```
|
||||
|
||||
Verifica Google:
|
||||
|
||||
```bash
|
||||
curl -sS "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://www.matchlivetv.it&relation=delegate_permission/common.handle_all_urls"
|
||||
```
|
||||
|
||||
Poi in Play Console → Link diretti → ripeti i controlli dominio.
|
||||
@@ -47,7 +47,7 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
|
||||
|
||||
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
|
||||
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
|
||||
|
||||
@@ -67,9 +67,9 @@ Vedi [YOUTUBE_MORNING_CHECKLIST.md](./YOUTUBE_MORNING_CHECKLIST.md) — APK **1.
|
||||
| YouTube disabilitato | Premium Light o Full |
|
||||
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
|
||||
| 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]`. |
|
||||
| YouTube «In programma» / copertina | Nessun intervento manuale: entro ~20–90s il server avvia overlay (tee RTMPS) e attiva la broadcast quando ingest è `active`. |
|
||||
| YouTube resta in «waiting» | `log/overlay_relay_<session_id>.log` in **sidekiq**; poll ogni 5s (`StreamPublisherSyncJob`). Attivazione ritenta ~6 min. |
|
||||
| Live non su YouTube | Pipeline `Youtube::LivePipeline` + `YoutubeRelay`. Controlla `docker logs infra-sidekiq-1` per `[YoutubeRelay] started` / `[YoutubeBroadcastActivate]`. |
|
||||
| YouTube «In programma» / copertina | Entro ~20–90s il server avvia il relay RTMPS e attiva la broadcast quando l’ingest è pronto; in pausa MediaMTX manda la slate. |
|
||||
| YouTube resta in «waiting» | Log `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. |
|
||||
| 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. |
|
||||
|
||||
+25
-19
@@ -2,7 +2,7 @@
|
||||
|
||||
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`
|
||||
|
||||
---
|
||||
@@ -193,39 +193,41 @@ Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md) per pause, overlay e player
|
||||
### 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)
|
||||
│
|
||||
▼ overlay ffmpeg (Streams::OverlayRelay)
|
||||
path live/match_{uuid}_air (tabellone + badge bruciati nel video)
|
||||
MediaMTX :1935 — path live/match_{uuid}
|
||||
│
|
||||
├──► 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 |
|
||||
|------------|--------|
|
||||
| `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 |
|
||||
| `Streams::OverlayRelay` | Ricodifica con PNG 1280×720 aggiornata ogni ~2s |
|
||||
| `Streams::YoutubeRelay` | Legge HLS da `mediamtx:8888` (non più via Rails) |
|
||||
| `Mediamtx::PublisherSync` | Stato publisher online/offline in Rails |
|
||||
| Overlay tabellone | **App nativa** (non più ricodifica server) |
|
||||
| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; remux copy A/V → YouTube |
|
||||
| `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à
|
||||
|
||||
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
- 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)
|
||||
|
||||
---
|
||||
@@ -278,8 +280,8 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails
|
||||
│
|
||||
Sidekiq ◄── Redis ◄──────┘
|
||||
│
|
||||
├── HealthMonitorJob, UploadJob, overlay refresh
|
||||
├── YoutubeRelay, OverlayRelay (ffmpeg)
|
||||
├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg)
|
||||
├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube)
|
||||
└── PublishToYoutubeJob, purge replay, ecc.
|
||||
```
|
||||
|
||||
@@ -395,10 +397,12 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
||||
| Documento | Contenuto |
|
||||
|-----------|-----------|
|
||||
| [`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 |
|
||||
| [`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 |
|
||||
| [`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 | HLS live: edge → MediaMTX (fuori da Puma) |
|
||||
| 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-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 |
|
||||
|
||||
+49
-34
@@ -1,23 +1,25 @@
|
||||
# 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}`):
|
||||
|
||||
1. **Telefono in onda** → publisher RTMP (camera).
|
||||
1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**).
|
||||
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
||||
3. **YouTube** → `ffmpeg` in container Rails/Sidekiq legge **sempre** quell’uscita (`-c copy`) e inoltra a `rtmp://a.rtmp.youtube.com/live2/...` per tutta la sessione.
|
||||
3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, `-c:a copy`; l’AAC 48 kHz mono è già prodotto dall’app / slate).
|
||||
|
||||
Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server.
|
||||
Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX.
|
||||
|
||||
## Componenti
|
||||
|
||||
| Pezzo | Ruolo |
|
||||
|--------|--------|
|
||||
| App Android | RTMP 48 kHz mono, 720p — solo quando in onda |
|
||||
| MediaMTX | Path dinamico, `alwaysAvailable` + slate |
|
||||
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (no hook wget su distroless) |
|
||||
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) |
|
||||
| App Android / iOS | RTMP 720p AAC mono; overlay tabellone/watermark in GPU sul flusso |
|
||||
| MediaMTX | Path dinamico, `alwaysAvailable` + slate, HLS |
|
||||
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (solo Sidekiq con `YOUTUBE_RELAY_WORKER=1`) |
|
||||
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) + recording on/off |
|
||||
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
|
||||
|
||||
## Pausa e continuità
|
||||
@@ -27,36 +29,44 @@ Il telefono **non** invia la copertina: risparmia banda; lo switch è lato serve
|
||||
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
||||
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.
|
||||
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.
|
||||
|
||||
**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`).
|
||||
|
||||
## Infrastruttura vs browser
|
||||
|
||||
```
|
||||
Telefono RTMP ──► MediaMTX path live/match_{uuid}
|
||||
│
|
||||
publisher ON ├─► camera (H.264/AAC)
|
||||
publisher OFF└─► alwaysAvailable → /slates/offline.mp4
|
||||
│
|
||||
├─► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX (prod) o proxy Rails (dev) ──► player web (HLS.js)
|
||||
└─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► YouTube
|
||||
Telefono RTMP (camera + overlay app)
|
||||
│
|
||||
▼
|
||||
MediaMTX path live/match_{uuid}
|
||||
│
|
||||
publisher ON → camera H.264/AAC (già con tabellone se overlay ≠ none)
|
||||
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 |
|
||||
|----------------|------|
|
||||
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
||||
| Continuità YouTube | **Relay ffmpeg** sulla stessa uscita |
|
||||
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) |
|
||||
| Tabellone / watermark sullo stream | **App nativa** (GPU → RTMP) |
|
||||
| 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) |
|
||||
| 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.
|
||||
|
||||
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)
|
||||
|
||||
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`.
|
||||
- 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.
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
Il tabellone è composito **sul telefono** prima dell’RTMP (vedi [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md)):
|
||||
|
||||
- `Streams::Overlay::SvgBuilder` + `rsvg-convert` generano `tmp/stream_overlays/{session_id}/overlay.png`
|
||||
- `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
|
||||
- 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
|
||||
- La pagina live **non** sovrappone più HTML sul player (evita incongruenze con fullscreen / YouTube)
|
||||
- **Qualità video**: il relay normalizza a **30 fps CFR** (l’app RTMP può inviare timestamp errati) e ricodifica a **≥4.5 Mbps** (`fast`, no B-frame). Override: `OVERLAY_RELAY_VIDEO_KBPS`, `OVERLAY_RELAY_X264_PRESET`, `OVERLAY_RELAY_FPS`.
|
||||
1. `OverlayState` da `effectiveOverlayKind` + `ScoreState`
|
||||
2. `OverlayRenderer` / canvas → bitmap
|
||||
3. Filtro GPU sul encoder RTMP
|
||||
|
||||
La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non sostituisce il tabellone nel video.
|
||||
|
||||
### Ruolo di ffmpeg oggi
|
||||
|
||||
| Quando | Cosa | Peso CPU |
|
||||
|--------|------|----------|
|
||||
| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video/audio** → RTMPS (remux) | Basso **per diretta**, scala con N processi |
|
||||
| Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero |
|
||||
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
|
||||
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
|
||||
|
||||
Non esiste più la ricodifica H.264 server-side dell’overlay (era il carico principale).
|
||||
|
||||
### Punteggio (persistenza)
|
||||
|
||||
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay.
|
||||
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `score_action`) → `Scoring::SyncState`, poi aggiorna l’overlay locale in camera.
|
||||
|
||||
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Esigenze custom: contatto `info@matchlive.it`.
|
||||
Esigenze custom: contatto `info@matchlivetv.it`.
|
||||
|
||||
## Sito
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
|
||||
|
||||
**Importante (2026-07):** l’overlay video è composito **solo sull’app nativa** (Android/iOS) e viaggia già nel RTMP verso MediaMTX. Non esiste più un relay server (`Streams::OverlayRelay`) che ricodifica con PNG. Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||
|
||||
---
|
||||
|
||||
## Panoramica
|
||||
@@ -162,12 +164,14 @@ Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellon
|
||||
|
||||
### Layout app mobile (Android e iOS)
|
||||
|
||||
Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva:
|
||||
Sull’app nativa l’overlay è composito in GPU **prima** dell’encode RTMP (nessuna ricodifica server aggiuntiva):
|
||||
|
||||
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
||||
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).
|
||||
|
||||
MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio).
|
||||
|
||||
Elementi grafici (`OverlayCanvasRenderer`):
|
||||
|
||||
| Elemento | Quando è attivo |
|
||||
|
||||
@@ -1,56 +1,32 @@
|
||||
# YouTube Live — verificato ✅ (5 giu 2026)
|
||||
# YouTube Live — checklist operativa
|
||||
|
||||
## Test completato automaticamente
|
||||
**Aggiornato:** 2026-07-29
|
||||
Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server.
|
||||
|
||||
Pipeline end-to-end **funzionante** sul server di produzione:
|
||||
|
||||
| Step | Esito |
|
||||
|------|--------|
|
||||
| Setup broadcast + stream_key | ✅ |
|
||||
| Overlay ffmpeg con tee RTMPS YouTube | ✅ |
|
||||
| Copertina / tabellone su YouTube | ✅ |
|
||||
| RTMP simulato (test pattern) | ✅ |
|
||||
| Broadcast `lifecycle=live` | ✅ |
|
||||
|
||||
**Prova visiva:** https://www.youtube.com/watch?v=0WmCsW_Luik
|
||||
(Titolo: *Tigers Volley vs Avversario prova* — sessione di test, ora terminata)
|
||||
|
||||
## APK da installare sul telefono
|
||||
|
||||
```bash
|
||||
# Sul PC (già compilato):
|
||||
mobile/build/app/outputs/flutter-apk/app-release.apk
|
||||
# Versione: 1.2.10+13 — API https://www.matchlivetv.it
|
||||
```
|
||||
|
||||
Installa sul Redmi (sostituisce versioni precedenti).
|
||||
|
||||
## Avvio diretta dall’app (2 minuti)
|
||||
|
||||
1. Partita → **YouTube Live** → wizard → **Camera**
|
||||
2. Attendi sul passo rete che compaia il link YouTube (poll automatico, fino a 3 min)
|
||||
3. Avvia: entro ~90s su YouTube compare **copertina**, poi video con tabellone quando il telefono pubblica RTMP
|
||||
|
||||
## Test server senza telefono
|
||||
## Verifica rapida su server
|
||||
|
||||
```bash
|
||||
ssh eminux@192.168.1.146
|
||||
docker exec infra-rails-1 bin/rails youtube:e2e
|
||||
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
|
||||
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 logs infra-sidekiq-1 2>&1 | grep -E 'YoutubeRelay|YoutubeBroadcastActivate|LivePipeline' | tail -40
|
||||
```
|
||||
|
||||
## Avvio diretta dall’app
|
||||
|
||||
1. Partita → **YouTube Live** → wizard → **Camera**
|
||||
2. Attendi sul passo rete che compaia il link YouTube (poll automatico)
|
||||
3. Avvia: entro ~90s su YouTube compare video (slate MediaMTX se il telefono non pubblica ancora; tabellone quando l’app pubblica RTMP con overlay)
|
||||
|
||||
## Se qualcosa non va
|
||||
|
||||
| Sintomo | Cosa fare |
|
||||
|---------|-----------|
|
||||
| «YouTube temporaneamente occupato» | Attendi 15–20 min (rate limit Google). **Non** creare molte sessioni di fila. |
|
||||
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep OverlayRelay` — manca `stream_key` o tee RTMPS |
|
||||
| App errore avvio diretta | APK 1.2.10+13; controlla rete verso matchlivetv.it |
|
||||
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep YoutubeRelay` — manca `stream_key` o relay non partito |
|
||||
| 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)
|
||||
- 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
|
||||
Checklist precedente (giu 2026) citava `OverlayRelay` / tee RTMPS dall’overlay server: **deprecato**. Non cercare più log `OverlayRelay` o `overlay_relay_*.log`.
|
||||
|
||||
@@ -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 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
|
||||
|
||||
- `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.
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
# Piano: overflow relay YouTube su Hetzner Cloud
|
||||
|
||||
**Stato:** piano / design — non implementato
|
||||
**Ultimo aggiornamento:** 2026-07-29
|
||||
**Contesto:** produzione attuale su host dedicato (es. Proxmox/Hetzner) con MediaMTX + Sidekiq sullo stesso box; obiettivo >10 live YouTube contemporanee senza abbandonare il middle-tier.
|
||||
|
||||
Documenti correlati: [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Scenario
|
||||
|
||||
### Problema di prodotto
|
||||
|
||||
Match Live TV disaccoppia il telefono da YouTube:
|
||||
|
||||
```text
|
||||
Telefono (rete mobile instabile)
|
||||
│ RTMP
|
||||
▼
|
||||
MediaMTX (sempre attivo, slate / alwaysAvailable)
|
||||
│
|
||||
├── HLS → sito
|
||||
└── YoutubeRelay (ffmpeg) → RTMPS YouTube
|
||||
```
|
||||
|
||||
Se cade solo il tratto telefono→MediaMTX, YouTube resta in onda (slate).
|
||||
Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la live pubblica cadrebbe con la rete mobile. Inoltre YouTube non è pensato come “Go Live dall’app mobile” senza vincoli di canale: noi usiamo API + RTMPS dal server.
|
||||
|
||||
### Problema di capacità
|
||||
|
||||
Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video+audio **copy** / remux).
|
||||
Il carico scala con **N processi**, non con gli spettatori HLS.
|
||||
|
||||
Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host):
|
||||
|
||||
| Live YT contemporanee | Situazione attesa |
|
||||
|-----------------------|-------------------|
|
||||
| 1–4 | Comodo |
|
||||
| 5–8 | Teso (CPU/RAM, latenza job Sidekiq) |
|
||||
| ≥10 | Rischioso (relay instabili, watchdog, slate prolungate su YT) |
|
||||
|
||||
**Obiettivo:** mantenere MediaMTX (e Rails) sul dedicated; quando N supera la soglia comoda, **accendere capacità Hetzner Cloud solo per i relay**, per il tempo del picco, poi spegnerla.
|
||||
|
||||
### Cosa NON è questo piano
|
||||
|
||||
- Non sostituisce MediaMTX con Mux/AWS MediaLive.
|
||||
- Non sposta l’ingest RTMP del telefono sul cloud (cold start e sticky URL sono incompatibili con “zero nodi a riposo”).
|
||||
- Non è autoscaling del monolite Docker Compose intero.
|
||||
|
||||
---
|
||||
|
||||
## 2. Principio di progettazione
|
||||
|
||||
| Componente | Dove resta | Perché |
|
||||
|------------|------------|--------|
|
||||
| MediaMTX (RTMP ingest + slate + HLS) | **Dedicated sempre acceso** | URL stabile per l’app; continuità YouTube via slate |
|
||||
| Rails / Postgres / Redis / Garage / edge | **Dedicated** (o gestiti fissi) | Stato, auth, replay; non sono il collo di bottiglia video live |
|
||||
| `YoutubeRelay` (ffmpeg) | **Dedicated fino a soglia + overflow Cloud** | Unico pezzo che moltiplica CPU con N live YT |
|
||||
| Job Sidekiq non-video (mail, ops, post-process) | Dedicated (coda separata) | Non devono competere con ffmpeg sui worker overflow |
|
||||
|
||||
Formula mentale:
|
||||
|
||||
```text
|
||||
capacità_yt ≈ core_dedicati_relay + Σ core_vm_overflow_calde
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Architettura target (overflow)
|
||||
|
||||
```text
|
||||
[ telefoni RTMP ]
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Dedicated (sempre on) │
|
||||
│ MediaMTX · Rails · DB │
|
||||
│ Redis · Garage · edge │
|
||||
│ Sidekiq "core" │
|
||||
│ (+ pochi relay locali) │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
intake RTMP/HLS │ (rete privata / tunnel / IP allowlist)
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
[relay-local] [overflow-1] [overflow-N]
|
||||
Sidekiq+ffmpeg Hetzner Cloud Hetzner Cloud
|
||||
YOUTUBE_RELAY=1 a ore a ore
|
||||
│ │ │
|
||||
└───────────────┴───────────────┘
|
||||
│
|
||||
▼
|
||||
YouTube RTMPS
|
||||
```
|
||||
|
||||
### Ruolo delle VM overflow
|
||||
|
||||
Immagine minimale (o Compose snello):
|
||||
|
||||
- Sidekiq con `YOUTUBE_RELAY_WORKER=1`
|
||||
- `ffmpeg` disponibile
|
||||
- Connessione a **Redis** del dedicated (coda + lock `youtube_relay:owner:*`)
|
||||
- Lettura intake da MediaMTX (RTMP preferito, HLS fallback come oggi)
|
||||
- **Niente** Postgres scrittura diretta obbligatoria se i job relay usano già Redis + API; in pratica oggi Rails/Sidekiq legge Postgres → le VM overflow devono raggiungere anche DB **oppure** si isola una coda “relay-only” con worker che riceve payload già risolto (vedi §5.2)
|
||||
|
||||
Stato attuale del codice: `YoutubeRelay` gira in processo Sidekiq Rails completo (accesso a `StreamSession`, MediaMTX client, Redis PID/owner). Le VM overflow sono quindi **worker Sidekiq Rails**, non demoni ffmpeg nudi — almeno nella fase 1.
|
||||
|
||||
---
|
||||
|
||||
## 4. Colli di bottiglia (cosa può rompersi comunque)
|
||||
|
||||
Anche con overflow infinito di CPU, restano limiti. Ordine di probabilità/impatto:
|
||||
|
||||
### 4.1 MediaMTX sul dedicated (alto)
|
||||
|
||||
- N publisher RTMP + N reader (HLS siti + N ffmpeg che rileggono lo stesso path).
|
||||
- Ogni relay in più è un **reader** su MediaMTX (RTMP pull o HLS).
|
||||
- Sintomi: path `ready` flaky, HLS a scatti sul sito, relay che rientrano in fallback HLS, CPU MediaMTX alta anche con relay scarichi.
|
||||
|
||||
**Mitigazione futura (fuori da questo overflow):** secondo nodo MediaMTX o sharding path; per ora monitorare `readers`, CPU `mediamtx`, bitrate aggregato.
|
||||
|
||||
### 4.2 Uplink Internet del dedicated (alto)
|
||||
|
||||
- Telefoni → dedicated (ingresso RTMP).
|
||||
- Dedicated → YouTube **se** i relay restano locali.
|
||||
- Con overflow Cloud: il dedicated manda **copia dello stream** verso le VM (pull dalle VM = traffico **uscita** dal dedicated verso Hetzner Cloud), poi le VM mandano a YouTube.
|
||||
|
||||
Attenzione al verso:
|
||||
|
||||
| Dove gira il relay | Traffico tipico |
|
||||
|--------------------|-----------------|
|
||||
| Sul dedicated | In: telefoni. Out: N× verso YouTube |
|
||||
| Su Hetzner Cloud | Out dedicated → Cloud (intake). Out Cloud → YouTube |
|
||||
|
||||
Se l’uplink casa/datacenter verso Internet è stretto, spostare i relay sul Cloud **sposta** il carico out YouTube fuori dal dedicated, ma **aggiunge** out dedicated→Cloud (stesso ordine di grandezza del video).
|
||||
Vantaggio reale solo se:
|
||||
|
||||
- l’uplink del dedicated satura soprattutto per **CPU** (oggi sì), oppure
|
||||
- dedicated e Cloud sono in **stessa regione Hetzner con rete privata** (traffico interno economico/veloce) e l’uscita verso YouTube esce dalla rete Hetzner Cloud (migliore peering).
|
||||
|
||||
**Decisione di rete obbligatoria prima di implementare** (vedi §6).
|
||||
|
||||
### 4.3 Redis / lock owner (medio)
|
||||
|
||||
Oggi:
|
||||
|
||||
- `youtube_relay:pid:%s` — PID locale al worker
|
||||
- `youtube_relay:owner:%s` — `HOSTNAME` del worker
|
||||
- `running?` considera attivo un owner remoto se TTL > 30s
|
||||
|
||||
Con più worker:
|
||||
|
||||
- Due ensure concorrenti possono avviare due ffmpeg (debounce 5s aiuta poco cross-host).
|
||||
- `stop` da Rails manda job: deve raggiungere il worker **owner**, non un overflow a caso.
|
||||
- Kill del PID funziona solo sulla macchina owner (`/proc`).
|
||||
|
||||
Serve disciplina di coda / routing (§5.3).
|
||||
|
||||
### 4.4 Cold start VM (medio)
|
||||
|
||||
Creare una CPX/CCX Hetzner: ordine di **30–90+ secondi** (API + boot + pull image + Sidekiq ready).
|
||||
Se la 15ª live parte e non c’è capacità, la broadcast YouTube resta “in attesa” finché non c’è worker.
|
||||
|
||||
**Requisito prodotto:** overflow **a caldo** = pool minimo già acceso nei weekend / fasce note, non “da zero al fischio”.
|
||||
|
||||
### 4.5 YouTube / account / quote (basso–medio, esterno)
|
||||
|
||||
- Limiti API, token OAuth, ban temporanei, ingest RTMPS rifiutato.
|
||||
- Non risolvibili con più CPU; restano nel runbook ops esistente.
|
||||
|
||||
### 4.6 Post-process replay (basso in live, alto a fine giornata)
|
||||
|
||||
`UploadFromSession` / thumbnail competono su Sidekiq e disco.
|
||||
I worker overflow **non** devono prendere coda `default` di merge se sono pensati solo per relay — altrimenti spegnendo le VM a fine picco uccidete job a metà.
|
||||
|
||||
### 4.7 Costo e idle (operativo)
|
||||
|
||||
VM dimenticate accese = bolletta. Serve TTL, scale-in aggressivo, alert “overflow acceso da >Xh con 0 relay”.
|
||||
|
||||
---
|
||||
|
||||
## 5. Complessità di implementazione
|
||||
|
||||
### 5.1 Rete: come le VM leggono MediaMTX
|
||||
|
||||
Opzioni (sceglierne **una** in fase 0):
|
||||
|
||||
| Opzione | Pro | Contro |
|
||||
|---------|-----|--------|
|
||||
| **A. Stessa location Hetzner + private network** (dedicated Robot + Cloud nella stessa rete) | Bassa latenza, traffico interno, modello pulito | Richiede che il dedicated sia (o diventi) in ecosistema Hetzner collegabile |
|
||||
| **B. WireGuard/Tailscale** dedicated ↔ Cloud | Funziona anche da Proxmox casa | Ops tunnel, MTU, failover; latenza |
|
||||
| **C. Esporre RTMP/HLS intake in lettura** (IP allowlist VM overflow) | Semplice | Superficie attacco; non esporre senza auth/TLS dove possibile |
|
||||
| **D. Relay locale che pusha a Cloud** | Dedicated controlla egress | Doppia hop, più pezzi |
|
||||
|
||||
Raccomandazione di piano: **A se il dedicated è/andrà su Hetzner; altrimenti B**. Evitare C in chiaro su WAN.
|
||||
|
||||
Variabili da prevedere sulle VM:
|
||||
|
||||
- `MEDIAMTX_INTERNAL_RTMP_URL` → URL raggiungibile dalle VM (non `rtmp://mediamtx:1935` Docker-locale)
|
||||
- `MEDIAMTX_HLS_URL` → idem
|
||||
- MediaMTX API (`:9997`) raggiungibile in privato per `intake_available?` / PublisherOnline, **mai** su Internet aperto
|
||||
|
||||
### 5.2 Accesso Postgres e segreti
|
||||
|
||||
Worker Sidekiq Rails oggi necessitano:
|
||||
|
||||
- `DATABASE_URL` (o replica read + job che non scrivono — irrealistico all’inizio)
|
||||
- `REDIS_URL`
|
||||
- `SECRET_KEY_BASE` / credenziali YouTube via DB
|
||||
- stessi env di produzione (ridotti)
|
||||
|
||||
Complessità: ogni overflow è un **nodo fidato** della rete app.
|
||||
Immagine Docker identica a `sidekiq` prod, env da secret manager o file scp/cloud-init, **nessuna** porta Rails pubblica sulle VM.
|
||||
|
||||
### 5.3 Code Sidekiq e affinità relay
|
||||
|
||||
Stato oggi: ensure/stop via job + processo locale con owner in Redis.
|
||||
|
||||
Per multi-host serve esplicitare:
|
||||
|
||||
1. **Coda dedicata** `youtube_relay` (solo worker con `YOUTUBE_RELAY_WORKER=1`).
|
||||
2. **Cap locale**: ogni worker ha `RELAY_MAX_CONCURRENT` (es. cores−1).
|
||||
3. **Scheduling**:
|
||||
- Fase 1 (manuale): overflow sempre in ascolto sulla coda; Sidekiq distribuisce i job; `ensure_on_worker!` no-op se a cap (re-enqueue o lascia ad altro worker).
|
||||
- Fase 2: controller di capacità che alza/abbassa N VM in base a `relay_attivi` e profondità coda.
|
||||
4. **Stop**: job `YoutubeRelayStopJob` deve eseguire **solo sull’owner** (Sidekiq unique + check `owner == HOSTNAME`, altrimenti requeue con delay breve).
|
||||
|
||||
Nota: i PID in Redis non sono globalmente killabili — il modello owner è già abbozzato; va reso robusto cross-host.
|
||||
|
||||
### 5.4 Scale-out / scale-in (lifecycle VM)
|
||||
|
||||
```text
|
||||
Metriche → Decisione → hcloud CLI/API → cloud-init → Sidekiq ready → in coda
|
||||
↘ fallisce → alert ops, non spegnere dedicated
|
||||
```
|
||||
|
||||
**Scale-out trigger (esempi):**
|
||||
|
||||
- `youtube_relay` depth > 0 per >60s **oppure**
|
||||
- `relay_attivi_local >= RELAY_SOFT_CAP` **e** nuove sessioni YT in `connecting|live`
|
||||
|
||||
**Scale-in trigger:**
|
||||
|
||||
- `relay_attivi` sulle VM overflow = 0 per >15–30 min **e**
|
||||
- fuori dalla finestra “weekend caldo” (opzionale)
|
||||
|
||||
**Warm pool:** sabato 8:00 → min 1–2 VM già up; domenica 23:00 → min 0.
|
||||
|
||||
### 5.5 Idempotenza e split-brain
|
||||
|
||||
Scenario da evitare: dedicated e overflow avviano entrambi ffmpeg sulla stessa `stream_key` → YouTube flappa.
|
||||
|
||||
Mitigazioni:
|
||||
|
||||
- Lock Redis con fencing token / TTL heartbeat rinnovato dal processo owner ogni N secondi
|
||||
- Watchdog (`YoutubeIngestWatchdogJob`) deve rispettare owner remoto (già parzialmente così via TTL)
|
||||
- Un solo writer della broadcast activate
|
||||
|
||||
### 5.6 Observability
|
||||
|
||||
Senza metriche l’overflow è cieco. Minimo:
|
||||
|
||||
| Metrica | Dove |
|
||||
|---------|------|
|
||||
| N relay ffmpeg vivi (local / per host) | Redis + `pgrep` / heartbeat |
|
||||
| CPU MediaMTX, CPU Sidekiq | node exporter / docker stats |
|
||||
| Profondità coda `youtube_relay` | Sidekiq API |
|
||||
| Bitrate / errori ffmpeg per session | log già in `log/youtube_relay_*.log` |
|
||||
| VM overflow accese, € stimati | tag Hetzner + cron report |
|
||||
| Latenza intake Cloud (RTT dedicated↔VM) | check periodico |
|
||||
|
||||
Alert ntfy esistenti: estendere con `relay_overflow_saturated`, `mediamtx_cpu_high`, `overflow_vm_orphan`.
|
||||
|
||||
### 5.7 Sicurezza
|
||||
|
||||
- Firewall: VM → solo Redis, Postgres, MediaMTX (porte interne), YouTube egress 443
|
||||
- Nessun SSH password; chiave o console Hetzner
|
||||
- Stream key YouTube solo in DB/encrypted; non in user-data in chiaro se evitabile
|
||||
- Image aggiornata; spegnimento = destroy, non “pausare” dischi dimenticati per mesi
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisioni aperte (bloccare prima del codice)
|
||||
|
||||
| # | Domanda | Impatto |
|
||||
|---|---------|---------|
|
||||
| D1 | Dedicated resta su LAN casa/Proxmox o migra/affianca Hetzner Robot? | Sceglie rete A vs B (§5.1) |
|
||||
| D2 | Soft cap relay sul dedicated (es. 4? 6?) | Quando scatta overflow |
|
||||
| D3 | Warm pool fisso weekend vs solo reactive | Cold start vs costo |
|
||||
| D4 | Tipo VM (CPX shared vs CCX dedicated CPU) | €/live e jitter AAC |
|
||||
| D5 | Stessa immagine `sidekiq` vs worker slim | Tempo boot e superficie |
|
||||
| D6 | Chi orchestra le VM? (script cron su dedicated, Terraform, small Go/Ruby service) | Ops ownership |
|
||||
|
||||
Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dimensionamento indicativo
|
||||
|
||||
Ipotesi: 720p, remux copy A/V — **~0.15–0.4 vCPU** medi per relay (picchi su analyze/reconnect).
|
||||
|
||||
| Target live YT | Dedicated (relay) | Overflow tipico | Note |
|
||||
|----------------|-------------------|-----------------|------|
|
||||
| ≤6 | Tutto locale | 0 | Situazione attuale “comoda” se box dedicato ai relay |
|
||||
| 10–15 | soft cap 4–6 | 1× VM 4–8 vCPU | Warm consigliato in giornata evento |
|
||||
| 20–30 | soft cap 4–6 | 2–4× VM | MediaMTX e uplink diventano critici |
|
||||
| 50+ | softire anche ingest | N VM + 2° MediaMTX | Fuori scope overflow-only |
|
||||
|
||||
Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg contro slate MediaMTX) prima di promettere SLA commerciali.
|
||||
|
||||
---
|
||||
|
||||
## 8. Piano a fasi (pronti a reagire, non a over-build)
|
||||
|
||||
### Fase 0 — Misura e soglie (1–2 giorni ops)
|
||||
|
||||
- [ ] Dashboard/manuale: N live YT, CPU `sidekiq`, CPU `mediamtx`, uplink
|
||||
- [ ] Definire `RELAY_SOFT_CAP` empirico sul dedicated
|
||||
- [ ] Chiudere **D1** (rete)
|
||||
- [ ] Load test controllato: 8–12 relay solo slate (senza telefoni) per vedere dove rompe
|
||||
|
||||
**Criterio go/no-go overflow:** saturazione CPU Sidekiq/ffmpeg **prima** di MediaMTX/uplink. Se saturano prima rete o MediaMTX, overflow Cloud non basta.
|
||||
|
||||
### Fase 1 — Overflow manuale “a caldo” (MVP ops)
|
||||
|
||||
- [ ] Snapshot/immagine Sidekiq relay-ready su Hetzner Cloud
|
||||
- [ ] cloud-init: env, WireGuard o private net, `docker compose up sidekiq`
|
||||
- [ ] Runbook: “accendi 1 VM”, verifica `YoutubeRelay` su HOSTNAME nuovo, “spegni”
|
||||
- [ ] Soft cap sul dedicated: nuovi ensure preferiscono coda se locali pieni (anche patch minima)
|
||||
- [ ] Alert se VM overflow up da >3h con 0 owner keys
|
||||
|
||||
**Reazione a incident:** operatore umano accende/spegne; nessun autoscaler ancora.
|
||||
|
||||
### Fase 2 — Routing coda robusto
|
||||
|
||||
- [ ] Coda `youtube_relay` isolata
|
||||
- [ ] Heartbeat owner Redis
|
||||
- [ ] Stop/ensure sticky all’owner
|
||||
- [ ] Cap per worker + requeue
|
||||
|
||||
### Fase 3 — Autoscaling controllato
|
||||
|
||||
- [ ] Job/cron: se saturazione → `hcloud server create` fino a `OVERFLOW_MAX`
|
||||
- [ ] Scale-in solo se idle e fuori warm window
|
||||
- [ ] Budget mensile + kill switch
|
||||
|
||||
### Fase 4 — Solo se necessario
|
||||
|
||||
- Secondo MediaMTX / sharding
|
||||
- ~~Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
|
||||
- Dedicated più grosso come baseline (spesso più economico dell’overflow cronico ogni weekend)
|
||||
|
||||
---
|
||||
|
||||
## 9. Runbook di reazione (anche prima dell’autoscaler)
|
||||
|
||||
### Sintomo: YouTube “in programma” / niente video con molte live
|
||||
|
||||
1. `docker stats` / CPU Sidekiq sul dedicated
|
||||
2. Contare relay: log `[YoutubeRelay] started` vs sessioni `platform=youtube` live
|
||||
3. Se CPU ~100% e MediaMTX ok → **accendere overflow** (Fase 1) o abbassare nuove live YT
|
||||
4. Se MediaMTX alto / path non ready → overflow **non** aiuta; ridurre reader o spostare HLS, non aggiungere pull Cloud ciechi
|
||||
|
||||
### Sintomo: live YT che si spezza solo sulle VM Cloud
|
||||
|
||||
1. RTT e packet loss dedicated↔VM
|
||||
2. Verificare che intake usi RTMP interno, non HLS pubblico via Internet
|
||||
3. Controllare doppio owner / due ffmpeg sulla stessa key
|
||||
|
||||
### Sintomo: bolletta Cloud anomala
|
||||
|
||||
1. Lista server con label `matchlivetv-overflow`
|
||||
2. Destroy idle
|
||||
3. Abbassare warm pool
|
||||
|
||||
### Sintomo: job replay morti dopo scale-in
|
||||
|
||||
1. Verificare che overflow **non** consumi coda `default` / `recordings`
|
||||
2. Riprocessare dead set Sidekiq
|
||||
|
||||
---
|
||||
|
||||
## 10. Criteri di successo
|
||||
|
||||
| Criterio | Misura |
|
||||
|----------|--------|
|
||||
| Picco gestito | ≥15 live YT contemporanee stabili in test |
|
||||
| Continuità prodotto | Drop telefono → slate YouTube ancora attiva (invariato) |
|
||||
| Costo | Overflow acceso solo in finestre picco; €/h documentato |
|
||||
| Ops | Runbook Fase 1 eseguibile in <10 min da operatore |
|
||||
| Non regressione | Live solo-sito (`matchlivetv`) e replay invariati |
|
||||
|
||||
---
|
||||
|
||||
## 11. Riepilogo esecutivo
|
||||
|
||||
Il pezzo che manca oltre ~10 live YouTube è **CPU dei relay**, non l’idea MediaMTX.
|
||||
Hetzner Cloud è la leva economica giusta se:
|
||||
|
||||
1. scalate **solo** i worker `YoutubeRelay`,
|
||||
2. tenete MediaMTX caldo sul dedicated,
|
||||
3. risolvete **rete privata** dedicated↔Cloud,
|
||||
4. partite da **warm pool + runbook manuale**, poi automate.
|
||||
|
||||
Il rischio principale non è “creare una VM”, è **leggere N volte MediaMTX e saturare uplink/MediaMTX** mentre pensate di aver risolto solo la CPU. La Fase 0 (misura) decide se l’overflow è sufficiente o se serve anche più ingest/baseline hardware.
|
||||
)
|
||||
+1
-1
@@ -14,7 +14,7 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
||||
|
||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ RAILS_LOG_LEVEL=info
|
||||
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
PRIVACY_CONTROLLER_VAT=
|
||||
|
||||
# Email transazionali (reset password, inviti)
|
||||
|
||||
@@ -72,7 +72,7 @@ services:
|
||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||
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}
|
||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||
PRIVACY_CONTROLLER_VAT: ${PRIVACY_CONTROLLER_VAT:-}
|
||||
@@ -150,6 +150,7 @@ services:
|
||||
volumes:
|
||||
- ./nginx-edge/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx-edge/maintenance.html:/usr/share/nginx/maintenance/index.html:ro
|
||||
- ./nginx-edge/well-known:/usr/share/nginx/well-known:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/edge-health"]
|
||||
interval: 15s
|
||||
|
||||
@@ -70,8 +70,8 @@ services:
|
||||
RAILS_LOG_TO_STDOUT: "true"
|
||||
PRIVACY_CONTROLLER_NAME: ${PRIVACY_CONTROLLER_NAME:-Emiliano Frascaro}
|
||||
PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)}
|
||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it}
|
||||
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlive.it>}
|
||||
PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlivetv.it}
|
||||
MAILER_FROM: ${MAILER_FROM:-Match Live TV <noreply@matchlivetv.it>}
|
||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
garage.prod.toml
|
||||
prod-credentials.env
|
||||
@@ -98,6 +98,13 @@ http {
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
# Android App Links (Play Console / Digital Asset Links)
|
||||
location = /.well-known/assetlinks.json {
|
||||
default_type application/json;
|
||||
add_header Cache-Control "public, max-age=300";
|
||||
alias /usr/share/nginx/well-known/assetlinks.json;
|
||||
}
|
||||
|
||||
# Health check ops: pass-through senza pagina di cortesia.
|
||||
location = /up {
|
||||
proxy_pass http://$rails_backend;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
"delegate_permission/common.get_login_creds"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.matchlivetv.match_live_tv",
|
||||
"sha256_cert_fingerprints": [
|
||||
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,4 +1,8 @@
|
||||
import java.io.File
|
||||
import java.util.Properties
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipFile
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
@@ -20,13 +24,18 @@ android {
|
||||
applicationId = "com.matchlivetv.match_live_tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 26
|
||||
versionName = "2.0.5-native"
|
||||
versionCode = 31
|
||||
versionName = "2.0.10-native"
|
||||
|
||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||
?: "https://www.matchlivetv.it"
|
||||
buildConfigField("String", "API_BASE_URL", "\"$apiBaseUrl\"")
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
// Anche in defaultConfig: AGP estrae simboli se le .so non sono già stripped.
|
||||
ndk {
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -47,6 +56,11 @@ android {
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
// Le .so AndroidX arrivano già stripped: AGP non genera metadata.
|
||||
// Il task injectNativeDebugSymbolsInReleaseBundle le incorpora nell'AAB.
|
||||
ndk {
|
||||
debugSymbolLevel = "SYMBOL_TABLE"
|
||||
}
|
||||
signingConfig = if (keystorePropertiesFile.exists()) {
|
||||
signingConfigs.getByName("release")
|
||||
} else {
|
||||
@@ -108,3 +122,118 @@ dependencies {
|
||||
androidTestImplementation("androidx.test:core:1.6.1")
|
||||
androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
|
||||
}
|
||||
|
||||
// Play Console: le .so prebuilt (AndroidX) sono già stripped → AGP salta l'estrazione
|
||||
// (niente BUNDLE-METADATA/com.android.tools.build.debugsymbols). Generiamo i .so.sym
|
||||
// come fa AGP (llvm-objcopy --strip-debug) e li iniettiamo nell'intermediary AAB.
|
||||
fun findLlvmObjcopy(): File {
|
||||
val ndkRoot = File(android.sdkDirectory, "ndk")
|
||||
val ndkDirs = ndkRoot.listFiles()?.filter { it.isDirectory }?.sortedByDescending { it.name }.orEmpty()
|
||||
for (ndk in ndkDirs) {
|
||||
val candidate = ndk.resolve("toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-objcopy")
|
||||
if (candidate.isFile) return candidate
|
||||
}
|
||||
error("llvm-objcopy non trovato sotto ${ndkRoot.absolutePath}")
|
||||
}
|
||||
|
||||
val injectNativeDebugSymbolsInReleaseBundle by tasks.registering {
|
||||
description = "Embed native-debug-symbols.zip (.so.sym) into the release AAB intermediary"
|
||||
val mergeNative = tasks.named("mergeReleaseNativeLibs")
|
||||
val packageBundle = tasks.named("packageReleaseBundle")
|
||||
dependsOn(mergeNative, packageBundle)
|
||||
inputs.dir(
|
||||
layout.buildDirectory.dir(
|
||||
"intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib",
|
||||
),
|
||||
)
|
||||
inputs.file(
|
||||
layout.buildDirectory.file(
|
||||
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
|
||||
),
|
||||
)
|
||||
outputs.file(
|
||||
layout.buildDirectory.file(
|
||||
"intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab",
|
||||
),
|
||||
)
|
||||
doLast {
|
||||
val libsDir = layout.buildDirectory
|
||||
.dir("intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib")
|
||||
.get()
|
||||
.asFile
|
||||
val aabFile = layout.buildDirectory
|
||||
.file("intermediates/intermediary_bundle/release/packageReleaseBundle/intermediary-bundle.aab")
|
||||
.get()
|
||||
.asFile
|
||||
require(libsDir.isDirectory) { "Native libs missing: $libsDir" }
|
||||
require(aabFile.isFile) { "Intermediary AAB missing: $aabFile" }
|
||||
|
||||
val objcopy = findLlvmObjcopy()
|
||||
val workDir = layout.buildDirectory.dir("tmp/native-debug-symbols-inject").get().asFile
|
||||
workDir.deleteRecursively()
|
||||
workDir.mkdirs()
|
||||
val symbolsRoot = workDir.resolve("symbols")
|
||||
symbolsRoot.mkdirs()
|
||||
|
||||
var count = 0
|
||||
libsDir.walkTopDown().filter { it.isFile && it.extension == "so" }.forEach { so ->
|
||||
val rel = so.relativeTo(libsDir).invariantSeparatorsPath
|
||||
val outRel = "$rel.sym"
|
||||
val outFile = symbolsRoot.resolve(outRel)
|
||||
outFile.parentFile.mkdirs()
|
||||
// Allineato ad AGP SYMBOL_TABLE: llvm-objcopy --strip-debug → *.so.sym
|
||||
val proc = ProcessBuilder(
|
||||
objcopy.absolutePath,
|
||||
"--strip-debug",
|
||||
so.absolutePath,
|
||||
outFile.absolutePath,
|
||||
).redirectErrorStream(true).start()
|
||||
val out = proc.inputStream.bufferedReader().readText()
|
||||
check(proc.waitFor() == 0) {
|
||||
"objcopy failed for $rel: $out"
|
||||
}
|
||||
count++
|
||||
}
|
||||
check(count > 0) { "Nessuna .so da cui generare simboli in $libsDir" }
|
||||
|
||||
val symbolsZip = workDir.resolve("native-debug-symbols.zip")
|
||||
ZipOutputStream(symbolsZip.outputStream().buffered()).use { zos ->
|
||||
symbolsRoot.walkTopDown().filter { it.isFile }.forEach { f ->
|
||||
val rel = f.relativeTo(symbolsRoot).invariantSeparatorsPath
|
||||
zos.putNextEntry(ZipEntry(rel))
|
||||
f.inputStream().use { it.copyTo(zos) }
|
||||
zos.closeEntry()
|
||||
}
|
||||
}
|
||||
|
||||
val metaPath = "BUNDLE-METADATA/com.android.tools.build.debugsymbols/native-debug-symbols.zip"
|
||||
val outAab = workDir.resolve("intermediary-with-symbols.aab")
|
||||
ZipFile(aabFile).use { src ->
|
||||
ZipOutputStream(outAab.outputStream().buffered()).use { dst ->
|
||||
val entries = src.entries()
|
||||
while (entries.hasMoreElements()) {
|
||||
val entry = entries.nextElement()
|
||||
if (entry.name == metaPath) continue
|
||||
val newEntry = ZipEntry(entry.name)
|
||||
newEntry.time = entry.time
|
||||
// Deflate all entries when rewriting; STORED copy is fragile across ZipFile.
|
||||
newEntry.method = ZipEntry.DEFLATED
|
||||
dst.putNextEntry(newEntry)
|
||||
src.getInputStream(entry).use { it.copyTo(dst) }
|
||||
dst.closeEntry()
|
||||
}
|
||||
dst.putNextEntry(ZipEntry(metaPath))
|
||||
symbolsZip.inputStream().use { it.copyTo(dst) }
|
||||
dst.closeEntry()
|
||||
}
|
||||
}
|
||||
outAab.copyTo(aabFile, overwrite = true)
|
||||
logger.lifecycle(
|
||||
"Injected $metaPath ($count .so.sym, ${symbolsZip.length()} bytes) into ${aabFile.name} via ${objcopy.name}",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name == "signReleaseBundle" }.configureEach {
|
||||
dependsOn(injectNativeDebugSymbolsInReleaseBundle)
|
||||
}
|
||||
|
||||
+7
-6
@@ -40,12 +40,13 @@ class ThermalStateManager(
|
||||
fun start() {
|
||||
monitor.onStateChanged = { next ->
|
||||
val previous = _state.value
|
||||
if (next == previous) return@onStateChanged
|
||||
Log.i(TAG, "[Thermal] $previous → $next")
|
||||
_state.value = next
|
||||
_noticeMessage.value = userNotice(next, previous)
|
||||
applyAdaptation(next, force = false)
|
||||
updateCriticalWatch(next)
|
||||
if (next != previous) {
|
||||
Log.i(TAG, "[Thermal] $previous → $next")
|
||||
_state.value = next
|
||||
_noticeMessage.value = userNotice(next, previous)
|
||||
applyAdaptation(next, force = false)
|
||||
updateCriticalWatch(next)
|
||||
}
|
||||
}
|
||||
monitor.start()
|
||||
_state.value = monitor.currentState
|
||||
|
||||
@@ -13,6 +13,22 @@ data class LoginRequest(val email: String, val password: String)
|
||||
|
||||
data class RefreshRequest(@Json(name = "refresh_token") val refreshToken: String)
|
||||
|
||||
data class ForgotPasswordRequest(val email: String)
|
||||
|
||||
data class ForgotPasswordResponse(val message: String)
|
||||
|
||||
data class UpdateAccountRequest(val name: String)
|
||||
|
||||
data class ChangePasswordRequest(
|
||||
@Json(name = "current_password") val currentPassword: String,
|
||||
val password: String,
|
||||
@Json(name = "password_confirmation") val passwordConfirmation: String,
|
||||
)
|
||||
|
||||
data class MessageResponse(val message: String)
|
||||
|
||||
data class ApiErrorResponse(val error: String? = null)
|
||||
|
||||
data class LoginResponse(
|
||||
val user: UserDto,
|
||||
@Json(name = "access_token") val accessToken: String,
|
||||
|
||||
+12
@@ -23,6 +23,18 @@ interface MatchLiveApi {
|
||||
@POST("auth/logout")
|
||||
suspend fun logout()
|
||||
|
||||
@POST("auth/password/forgot")
|
||||
suspend fun forgotPassword(@Body body: ForgotPasswordRequest): ForgotPasswordResponse
|
||||
|
||||
@GET("account")
|
||||
suspend fun account(): UserDto
|
||||
|
||||
@PATCH("account")
|
||||
suspend fun updateAccount(@Body body: UpdateAccountRequest): UserDto
|
||||
|
||||
@PATCH("account/password")
|
||||
suspend fun changePassword(@Body body: ChangePasswordRequest): MessageResponse
|
||||
|
||||
@GET("sports")
|
||||
suspend fun sports(): List<SportDto>
|
||||
|
||||
|
||||
+31
@@ -2,9 +2,13 @@ package com.matchlivetv.match_live_tv.data.repository
|
||||
|
||||
import com.matchlivetv.match_live_tv.core.StoredSession
|
||||
import com.matchlivetv.match_live_tv.core.TokenStore
|
||||
import com.matchlivetv.match_live_tv.data.api.ChangePasswordRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.ForgotPasswordRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.LoginRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||
import com.matchlivetv.match_live_tv.data.api.RefreshRequest
|
||||
import com.matchlivetv.match_live_tv.data.api.UpdateAccountRequest
|
||||
import com.matchlivetv.match_live_tv.domain.User
|
||||
import kotlinx.coroutines.flow.first
|
||||
|
||||
class AuthRepository(
|
||||
@@ -46,6 +50,33 @@ class AuthRepository(
|
||||
return session
|
||||
}
|
||||
|
||||
suspend fun forgotPassword(email: String): String {
|
||||
return api.forgotPassword(ForgotPasswordRequest(email.trim())).message
|
||||
}
|
||||
|
||||
suspend fun fetchAccount(): User = api.account().toDomain()
|
||||
|
||||
suspend fun updateName(name: String): User {
|
||||
val user = api.updateAccount(UpdateAccountRequest(name.trim())).toDomain()
|
||||
val stored = currentSession() ?: return user
|
||||
tokenStore.saveSession(stored.copy(user = user))
|
||||
return user
|
||||
}
|
||||
|
||||
suspend fun changePassword(
|
||||
currentPassword: String,
|
||||
password: String,
|
||||
passwordConfirmation: String,
|
||||
) {
|
||||
api.changePassword(
|
||||
ChangePasswordRequest(
|
||||
currentPassword = currentPassword,
|
||||
password = password,
|
||||
passwordConfirmation = passwordConfirmation,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun logout() {
|
||||
runCatching { api.logout() }
|
||||
tokenStore.clear()
|
||||
|
||||
+11
-1
@@ -1,5 +1,9 @@
|
||||
package com.matchlivetv.match_live_tv.streaming
|
||||
|
||||
/**
|
||||
* Profilo RTMP allineato a MediaMTX slate + relay YouTube (copy audio/video).
|
||||
* Audio: AAC 48 kHz mono 128 kbps — stesso contratto di `infra/scripts/generate_slate.sh`.
|
||||
*/
|
||||
data class BroadcastConfig(
|
||||
val rtmpUrl: String,
|
||||
val width: Int = 1280,
|
||||
@@ -11,7 +15,13 @@ data class BroadcastConfig(
|
||||
val portrait: Boolean = false,
|
||||
val maxReconnectAttempts: Int = 10,
|
||||
val reconnectDelayMs: Long = 5_000L,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
const val AUDIO_SAMPLE_RATE_HZ = 48_000
|
||||
/** false = mono in RootEncoder `prepareAudio(sampleRate, isStereo, bitrate)`. */
|
||||
const val AUDIO_STEREO = false
|
||||
}
|
||||
}
|
||||
|
||||
enum class BroadcastPhase {
|
||||
IDLE,
|
||||
|
||||
+5
-1
@@ -316,7 +316,11 @@ class LiveBroadcastEngine(
|
||||
rotation = cfg.rotation,
|
||||
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||
level = h264Level,
|
||||
) && stream.prepareAudio(48_000, false, cfg.audioBitrate)
|
||||
) && stream.prepareAudio(
|
||||
BroadcastConfig.AUDIO_SAMPLE_RATE_HZ,
|
||||
BroadcastConfig.AUDIO_STEREO,
|
||||
cfg.audioBitrate,
|
||||
)
|
||||
}.getOrElse {
|
||||
Log.w(TAG, "preparePipeline: ${it.message}")
|
||||
false
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
package com.matchlivetv.match_live_tv.ui.account
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchSecondaryButton
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.HttpException
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountScreen(
|
||||
container: AppContainer,
|
||||
onBack: () -> Unit,
|
||||
onLoggedOut: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var email by remember { mutableStateOf("") }
|
||||
var role by remember { mutableStateOf("") }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var currentPassword by remember { mutableStateOf("") }
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var loadingProfile by remember { mutableStateOf(true) }
|
||||
var savingProfile by remember { mutableStateOf(false) }
|
||||
var savingPassword by remember { mutableStateOf(false) }
|
||||
var loggingOut by remember { mutableStateOf(false) }
|
||||
var profileMessage by remember { mutableStateOf<String?>(null) }
|
||||
var profileError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordMessage by remember { mutableStateOf<String?>(null) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val errGeneric = stringResource(R.string.common_error_generic)
|
||||
val profileSaved = stringResource(R.string.account_profile_saved)
|
||||
val passwordSaved = stringResource(R.string.account_password_saved)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
runCatching { container.authRepository.fetchAccount() }
|
||||
.onSuccess { user ->
|
||||
email = user.email
|
||||
name = user.name
|
||||
role = user.role
|
||||
}
|
||||
.onFailure {
|
||||
profileError = apiErrorMessage(it) ?: errGeneric
|
||||
}
|
||||
loadingProfile = false
|
||||
}
|
||||
|
||||
MatchScreenScaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.account_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.common_close),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MatchColors.Background,
|
||||
titleContentColor = Color.White,
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.account_profile_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = {},
|
||||
enabled = false,
|
||||
label = { Text(stringResource(R.string.login_email)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = accountFieldColors(),
|
||||
)
|
||||
if (role.isNotBlank()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.account_role, role),
|
||||
color = MatchColors.TextSecondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.account_name_label)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !loadingProfile && !savingProfile,
|
||||
colors = accountFieldColors(),
|
||||
)
|
||||
profileError?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MatchColors.PrimaryRed)
|
||||
}
|
||||
profileMessage?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MatchColors.TextSecondary)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MatchPrimaryButton(
|
||||
label = stringResource(R.string.account_save_profile),
|
||||
loading = savingProfile,
|
||||
enabled = !loadingProfile && name.isNotBlank() && !savingProfile,
|
||||
onClick = {
|
||||
savingProfile = true
|
||||
profileError = null
|
||||
profileMessage = null
|
||||
scope.launch {
|
||||
runCatching { container.authRepository.updateName(name) }
|
||||
.onSuccess {
|
||||
name = it.name
|
||||
profileMessage = profileSaved
|
||||
}
|
||||
.onFailure {
|
||||
profileError = apiErrorMessage(it) ?: errGeneric
|
||||
}
|
||||
savingProfile = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(32.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.account_password_heading),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = Color.White,
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PasswordField(
|
||||
value = currentPassword,
|
||||
onValueChange = { currentPassword = it },
|
||||
label = stringResource(R.string.account_current_password),
|
||||
visible = passwordVisible,
|
||||
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PasswordField(
|
||||
value = newPassword,
|
||||
onValueChange = { newPassword = it },
|
||||
label = stringResource(R.string.account_new_password),
|
||||
visible = passwordVisible,
|
||||
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
PasswordField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = stringResource(R.string.account_confirm_password),
|
||||
visible = passwordVisible,
|
||||
onToggleVisible = { passwordVisible = !passwordVisible },
|
||||
)
|
||||
passwordError?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MatchColors.PrimaryRed)
|
||||
}
|
||||
passwordMessage?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(it, color = MatchColors.TextSecondary)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
MatchPrimaryButton(
|
||||
label = stringResource(R.string.account_save_password),
|
||||
loading = savingPassword,
|
||||
enabled = currentPassword.isNotBlank() &&
|
||||
newPassword.isNotBlank() &&
|
||||
confirmPassword.isNotBlank() &&
|
||||
!savingPassword,
|
||||
onClick = {
|
||||
savingPassword = true
|
||||
passwordError = null
|
||||
passwordMessage = null
|
||||
scope.launch {
|
||||
runCatching {
|
||||
container.authRepository.changePassword(
|
||||
currentPassword = currentPassword,
|
||||
password = newPassword,
|
||||
passwordConfirmation = confirmPassword,
|
||||
)
|
||||
}.onSuccess {
|
||||
currentPassword = ""
|
||||
newPassword = ""
|
||||
confirmPassword = ""
|
||||
passwordMessage = passwordSaved
|
||||
}.onFailure {
|
||||
passwordError = apiErrorMessage(it) ?: errGeneric
|
||||
}
|
||||
savingPassword = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(40.dp))
|
||||
MatchSecondaryButton(
|
||||
label = stringResource(R.string.action_logout),
|
||||
enabled = !loggingOut,
|
||||
onClick = {
|
||||
loggingOut = true
|
||||
scope.launch {
|
||||
container.authRepository.logout()
|
||||
onLoggedOut()
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PasswordField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
visible: Boolean,
|
||||
onToggleVisible: () -> Unit,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onToggleVisible) {
|
||||
Icon(
|
||||
imageVector = if (visible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||
contentDescription = null,
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
colors = accountFieldColors(),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun accountFieldColors() = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
disabledTextColor = MatchColors.TextSecondary,
|
||||
focusedBorderColor = MatchColors.PrimaryRed,
|
||||
unfocusedBorderColor = MatchColors.Outline,
|
||||
disabledBorderColor = MatchColors.Outline,
|
||||
focusedLabelColor = MatchColors.PrimaryRed,
|
||||
unfocusedLabelColor = MatchColors.TextSecondary,
|
||||
disabledLabelColor = MatchColors.TextSecondary,
|
||||
cursorColor = MatchColors.PrimaryRed,
|
||||
)
|
||||
|
||||
private fun apiErrorMessage(error: Throwable): String? {
|
||||
if (error is HttpException) {
|
||||
val body = error.response()?.errorBody()?.string().orEmpty()
|
||||
val match = Regex("\"error\"\\s*:\\s*\"([^\"]+)\"").find(body)
|
||||
if (match != null) return match.groupValues[1]
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
package com.matchlivetv.match_live_tv.ui.login
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ForgotPasswordScreen(
|
||||
container: AppContainer,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var success by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val errGeneric = stringResource(R.string.common_error_generic)
|
||||
val successMessage = stringResource(R.string.forgot_password_success)
|
||||
|
||||
MatchScreenScaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.forgot_password_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.common_close),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MatchColors.Background,
|
||||
titleContentColor = Color.White,
|
||||
),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.forgot_password_lead),
|
||||
color = MatchColors.TextSecondary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedTextField(
|
||||
value = email,
|
||||
onValueChange = { email = it },
|
||||
label = { Text(stringResource(R.string.login_email)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
enabled = !success,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Email,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { }),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedTextColor = Color.White,
|
||||
unfocusedTextColor = Color.White,
|
||||
focusedBorderColor = MatchColors.PrimaryRed,
|
||||
unfocusedBorderColor = MatchColors.Outline,
|
||||
focusedLabelColor = MatchColors.PrimaryRed,
|
||||
unfocusedLabelColor = MatchColors.TextSecondary,
|
||||
cursorColor = MatchColors.PrimaryRed,
|
||||
),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(it, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
|
||||
}
|
||||
if (success) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(successMessage, color = MatchColors.TextSecondary, textAlign = TextAlign.Center)
|
||||
}
|
||||
Spacer(Modifier.height(32.dp))
|
||||
MatchPrimaryButton(
|
||||
label = stringResource(R.string.forgot_password_submit),
|
||||
loading = loading,
|
||||
enabled = email.isNotBlank() && !loading && !success,
|
||||
onClick = {
|
||||
loading = true
|
||||
error = null
|
||||
scope.launch {
|
||||
runCatching { container.authRepository.forgotPassword(email) }
|
||||
.onSuccess { success = true }
|
||||
.onFailure { error = it.message ?: errGeneric }
|
||||
loading = false
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -1,5 +1,6 @@
|
||||
package com.matchlivetv.match_live_tv.ui.login
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -38,6 +39,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
@@ -53,6 +55,7 @@ import kotlinx.coroutines.launch
|
||||
fun LoginScreen(
|
||||
container: AppContainer,
|
||||
onLoggedIn: () -> Unit,
|
||||
onForgotPassword: () -> Unit,
|
||||
) {
|
||||
var email by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
@@ -170,6 +173,16 @@ fun LoginScreen(
|
||||
keyboardActions = KeyboardActions(onDone = { submitLogin() }),
|
||||
colors = matchTextFieldColors(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.login_forgot_password),
|
||||
color = MatchColors.TextSecondary,
|
||||
textDecoration = TextDecoration.Underline,
|
||||
modifier = Modifier
|
||||
.align(Alignment.End)
|
||||
.clickable(onClick = onForgotPassword)
|
||||
.padding(vertical = 4.dp),
|
||||
)
|
||||
error?.let {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(
|
||||
|
||||
+5
-10
@@ -17,9 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Language
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -67,7 +67,7 @@ fun MatchesScreen(
|
||||
container: AppContainer,
|
||||
onOpenSetup: (matchId: String) -> Unit,
|
||||
onOpenBroadcast: (sessionId: String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onOpenAccount: () -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -179,15 +179,10 @@ fun MatchesScreen(
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
container.authRepository.logout()
|
||||
onLogout()
|
||||
}
|
||||
}) {
|
||||
IconButton(onClick = onOpenAccount) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Logout,
|
||||
contentDescription = stringResource(R.string.action_logout),
|
||||
imageVector = Icons.Filled.Person,
|
||||
contentDescription = stringResource(R.string.account_title),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
+30
-7
@@ -7,7 +7,9 @@ import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.ui.account.AccountScreen
|
||||
import com.matchlivetv.match_live_tv.ui.broadcast.BroadcastScreen
|
||||
import com.matchlivetv.match_live_tv.ui.login.ForgotPasswordScreen
|
||||
import com.matchlivetv.match_live_tv.ui.login.LoginScreen
|
||||
import com.matchlivetv.match_live_tv.ui.matches.MatchesScreen
|
||||
import com.matchlivetv.match_live_tv.ui.splash.SplashScreen
|
||||
@@ -34,11 +36,23 @@ fun AppNavHost(container: AppContainer) {
|
||||
)
|
||||
}
|
||||
composable(Routes.Login) {
|
||||
LoginScreen(container) {
|
||||
navController.navigate(Routes.Matches) {
|
||||
popUpTo(Routes.Login) { inclusive = true }
|
||||
}
|
||||
}
|
||||
LoginScreen(
|
||||
container = container,
|
||||
onLoggedIn = {
|
||||
navController.navigate(Routes.Matches) {
|
||||
popUpTo(Routes.Login) { inclusive = true }
|
||||
}
|
||||
},
|
||||
onForgotPassword = {
|
||||
navController.navigate(Routes.ForgotPassword)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.ForgotPassword) {
|
||||
ForgotPasswordScreen(
|
||||
container = container,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.Matches) {
|
||||
MatchesScreen(
|
||||
@@ -49,9 +63,18 @@ fun AppNavHost(container: AppContainer) {
|
||||
onOpenBroadcast = { sessionId ->
|
||||
navController.navigate(Routes.broadcast(sessionId))
|
||||
},
|
||||
onLogout = {
|
||||
onOpenAccount = {
|
||||
navController.navigate(Routes.Account)
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Routes.Account) {
|
||||
AccountScreen(
|
||||
container = container,
|
||||
onBack = { navController.popBackStack() },
|
||||
onLoggedOut = {
|
||||
navController.navigate(Routes.Login) {
|
||||
popUpTo(Routes.Matches) { inclusive = true }
|
||||
popUpTo(0) { inclusive = true }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
+2
@@ -3,7 +3,9 @@ package com.matchlivetv.match_live_tv.ui.navigation
|
||||
object Routes {
|
||||
const val Splash = "splash"
|
||||
const val Login = "login"
|
||||
const val ForgotPassword = "forgot_password"
|
||||
const val Matches = "matches"
|
||||
const val Account = "account"
|
||||
const val Setup = "setup/{matchId}/{step}"
|
||||
const val Broadcast = "broadcast/{sessionId}"
|
||||
|
||||
|
||||
@@ -241,4 +241,21 @@ Dies kann nicht rückgängig gemacht werden.</string>
|
||||
<string name="login_email_placeholder">coach@team.com</string>
|
||||
<string name="action_delete">Löschen</string>
|
||||
<string name="wizard_color_hue">Farbton</string>
|
||||
<string name="login_forgot_password">Passwort vergessen?</string>
|
||||
<string name="forgot_password_title">Passwort vergessen</string>
|
||||
<string name="forgot_password_lead">Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts.</string>
|
||||
<string name="forgot_password_submit">Reset-Link senden</string>
|
||||
<string name="forgot_password_success">Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen.</string>
|
||||
<string name="account_title">Konto</string>
|
||||
<string name="account_profile_heading">Profil</string>
|
||||
<string name="account_password_heading">Passwort ändern</string>
|
||||
<string name="account_name_label">Name</string>
|
||||
<string name="account_role">Rolle: %1$s</string>
|
||||
<string name="account_current_password">Aktuelles Passwort</string>
|
||||
<string name="account_new_password">Neues Passwort (mind. 8 Zeichen)</string>
|
||||
<string name="account_confirm_password">Passwort bestätigen</string>
|
||||
<string name="account_save_profile">Profil speichern</string>
|
||||
<string name="account_save_password">Passwort aktualisieren</string>
|
||||
<string name="account_profile_saved">Profil aktualisiert</string>
|
||||
<string name="account_password_saved">Passwort aktualisiert</string>
|
||||
</resources>
|
||||
|
||||
@@ -241,4 +241,21 @@ This cannot be undone.</string>
|
||||
<string name="login_email_placeholder">coach@team.com</string>
|
||||
<string name="action_delete">Delete</string>
|
||||
<string name="wizard_color_hue">Hue</string>
|
||||
<string name="login_forgot_password">Forgot password?</string>
|
||||
<string name="forgot_password_title">Forgot password</string>
|
||||
<string name="forgot_password_lead">Enter your account email: we will send you a link to reset your password.</string>
|
||||
<string name="forgot_password_submit">Send reset link</string>
|
||||
<string name="forgot_password_success">If the email is registered, you will receive a password reset link shortly.</string>
|
||||
<string name="account_title">Account</string>
|
||||
<string name="account_profile_heading">Profile</string>
|
||||
<string name="account_password_heading">Change password</string>
|
||||
<string name="account_name_label">Name</string>
|
||||
<string name="account_role">Role: %1$s</string>
|
||||
<string name="account_current_password">Current password</string>
|
||||
<string name="account_new_password">New password (min. 8 characters)</string>
|
||||
<string name="account_confirm_password">Confirm password</string>
|
||||
<string name="account_save_profile">Save profile</string>
|
||||
<string name="account_save_password">Update password</string>
|
||||
<string name="account_profile_saved">Profile updated</string>
|
||||
<string name="account_password_saved">Password updated</string>
|
||||
</resources>
|
||||
|
||||
@@ -241,4 +241,21 @@ Esta acción no se puede deshacer.</string>
|
||||
<string name="login_email_placeholder">coach@equipo.com</string>
|
||||
<string name="action_delete">Eliminar</string>
|
||||
<string name="wizard_color_hue">Tono</string>
|
||||
<string name="login_forgot_password">¿Olvidaste la contraseña?</string>
|
||||
<string name="forgot_password_title">Contraseña olvidada</string>
|
||||
<string name="forgot_password_lead">Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña.</string>
|
||||
<string name="forgot_password_submit">Enviar enlace</string>
|
||||
<string name="forgot_password_success">Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña.</string>
|
||||
<string name="account_title">Cuenta</string>
|
||||
<string name="account_profile_heading">Perfil</string>
|
||||
<string name="account_password_heading">Cambiar contraseña</string>
|
||||
<string name="account_name_label">Nombre</string>
|
||||
<string name="account_role">Rol: %1$s</string>
|
||||
<string name="account_current_password">Contraseña actual</string>
|
||||
<string name="account_new_password">Nueva contraseña (mín. 8 caracteres)</string>
|
||||
<string name="account_confirm_password">Confirmar contraseña</string>
|
||||
<string name="account_save_profile">Guardar perfil</string>
|
||||
<string name="account_save_password">Actualizar contraseña</string>
|
||||
<string name="account_profile_saved">Perfil actualizado</string>
|
||||
<string name="account_password_saved">Contraseña actualizada</string>
|
||||
</resources>
|
||||
|
||||
@@ -241,4 +241,21 @@ Cette action est irréversible.</string>
|
||||
<string name="login_email_placeholder">coach@equipe.com</string>
|
||||
<string name="action_delete">Supprimer</string>
|
||||
<string name="wizard_color_hue">Teinte</string>
|
||||
<string name="login_forgot_password">Mot de passe oublié ?</string>
|
||||
<string name="forgot_password_title">Mot de passe oublié</string>
|
||||
<string name="forgot_password_lead">Saisissez l’e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe.</string>
|
||||
<string name="forgot_password_submit">Envoyer le lien</string>
|
||||
<string name="forgot_password_success">Si l’e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation.</string>
|
||||
<string name="account_title">Compte</string>
|
||||
<string name="account_profile_heading">Profil</string>
|
||||
<string name="account_password_heading">Changer le mot de passe</string>
|
||||
<string name="account_name_label">Nom</string>
|
||||
<string name="account_role">Rôle : %1$s</string>
|
||||
<string name="account_current_password">Mot de passe actuel</string>
|
||||
<string name="account_new_password">Nouveau mot de passe (min. 8 caractères)</string>
|
||||
<string name="account_confirm_password">Confirmer le mot de passe</string>
|
||||
<string name="account_save_profile">Enregistrer le profil</string>
|
||||
<string name="account_save_password">Mettre à jour le mot de passe</string>
|
||||
<string name="account_profile_saved">Profil mis à jour</string>
|
||||
<string name="account_password_saved">Mot de passe mis à jour</string>
|
||||
</resources>
|
||||
|
||||
@@ -241,4 +241,21 @@ L\'operazione non si può annullare.</string>
|
||||
<string name="login_email_placeholder">coach@team.com</string>
|
||||
<string name="action_delete">Elimina</string>
|
||||
<string name="wizard_color_hue">Tonalità</string>
|
||||
<string name="login_forgot_password">Password dimenticata?</string>
|
||||
<string name="forgot_password_title">Password dimenticata</string>
|
||||
<string name="forgot_password_lead">Inserisci l’email dell’account: ti invieremo un link per reimpostare la password.</string>
|
||||
<string name="forgot_password_submit">Invia link di reset</string>
|
||||
<string name="forgot_password_success">Se l’email è registrata, riceverai a breve un link per reimpostare la password.</string>
|
||||
<string name="account_title">Account</string>
|
||||
<string name="account_profile_heading">Profilo</string>
|
||||
<string name="account_password_heading">Cambia password</string>
|
||||
<string name="account_name_label">Nome</string>
|
||||
<string name="account_role">Ruolo: %1$s</string>
|
||||
<string name="account_current_password">Password attuale</string>
|
||||
<string name="account_new_password">Nuova password (min. 8 caratteri)</string>
|
||||
<string name="account_confirm_password">Conferma password</string>
|
||||
<string name="account_save_profile">Salva profilo</string>
|
||||
<string name="account_save_password">Aggiorna password</string>
|
||||
<string name="account_profile_saved">Profilo aggiornato</string>
|
||||
<string name="account_password_saved">Password aggiornata</string>
|
||||
</resources>
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchScreenScaffold.swift; path = MatchLiveTv/UI/Components/MatchScreenScaffold.swift; sourceTree = "<group>"; };
|
||||
3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchSecondaryButton.swift; path = MatchLiveTv/UI/Components/MatchSecondaryButton.swift; sourceTree = "<group>"; };
|
||||
1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchStatusBadge.swift; path = MatchLiveTv/UI/Components/MatchStatusBadge.swift; sourceTree = "<group>"; };
|
||||
E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountScreen.swift; path = MatchLiveTv/UI/Account/AccountScreen.swift; sourceTree = "<group>"; };
|
||||
DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ForgotPasswordScreen.swift; path = MatchLiveTv/UI/Login/ForgotPasswordScreen.swift; sourceTree = "<group>"; };
|
||||
DA51BC65490E421AA3D16F02 /* LoginScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LoginScreen.swift; path = MatchLiveTv/UI/Login/LoginScreen.swift; sourceTree = "<group>"; };
|
||||
0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MatchesScreen.swift; path = MatchLiveTv/UI/Matches/MatchesScreen.swift; sourceTree = "<group>"; };
|
||||
5DD00428364442C6B8C55DDF /* AppNavHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppNavHost.swift; path = MatchLiveTv/UI/Navigation/AppNavHost.swift; sourceTree = "<group>"; };
|
||||
@@ -143,6 +145,8 @@
|
||||
D71CB837FE844932AA590D30 /* MatchScreenScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7434175C1E849D7A8308896 /* MatchScreenScaffold.swift */; };
|
||||
52806453AD4C4E59BA2F05DE /* MatchSecondaryButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE0F41386A741B2A6FC538B /* MatchSecondaryButton.swift */; };
|
||||
FA601701F8684EE2BFDBBBFD /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F6A499FE7D94EF498DA9A84 /* MatchStatusBadge.swift */; };
|
||||
A85ED43E903E4983B1438D11 /* AccountScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13972C3F4664A89AE13D5D8 /* AccountScreen.swift */; };
|
||||
195AF301A0AC4BA58F8EDE95 /* ForgotPasswordScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF38B8E56394DEBA022F812 /* ForgotPasswordScreen.swift */; };
|
||||
8C0225C7F87A4F9A8A0C2919 /* LoginScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA51BC65490E421AA3D16F02 /* LoginScreen.swift */; };
|
||||
7293CD3C1B634333A59FA782 /* MatchesScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A439BB96CCB4C038EEF44DD /* MatchesScreen.swift */; };
|
||||
465980490F2D417E87241EBA /* AppNavHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DD00428364442C6B8C55DDF /* AppNavHost.swift */; };
|
||||
@@ -176,10 +180,10 @@
|
||||
9246A8532B66443A83535192 /* de.lproj in Resources */ = {isa = PBXBuildFile; fileRef = 85B0F928887C44128434974E; };
|
||||
5DA941A5928A4F638223030C /* es.lproj in Resources */ = {isa = PBXBuildFile; fileRef = E907C350C3E5410FB4A2038D; };
|
||||
B6A69164398F4F9FB84EC475 = {isa = PBXGroup; children = (86BA4622CE11450BAD7902B9, E068E1D4709041789B1E8F5A, 2165E0C34A8B43F28AC2B21C); sourceTree = "<group>"; };
|
||||
86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = "<group>"; };
|
||||
86BA4622CE11450BAD7902B9 = {isa = PBXGroup; children = (4FC590728C2F47DE94AEADA3, 69F95F7CAF894139BA244AFF, 5E30D25B02704FAE9C0A4040, A59093CB86BB41FE9DF37182, C00F6C82C9A04A50B1D34BA3, 6F83B04D0BC84879A07C9BFE, 33DEE4675BAB413E9F69549B, 14FE2B5F844A482C91245755, AA9DF2F1C5F04EA198286266, 8EEFEB75DDB04C4986774D4B, C84694361A004982B4007CA5, 1AD71EE7F4A54818A6C427C9, 610902FBCEB7478286C88C08, DD9687B43B5840DD96FAD1C9, 905A6416560A4442AA7D2FCF, 9299E98557E04C5F8DC736F8, 49E1D454901048A58DCE7C41, A90696A40A584C5794938FDA, 2C3F8293D48D44A6A569F452, 347DADA50AE44484ABF19903, 69794703A6D74DB9AA3D16A2, D274E6DA2B7B47C9822FB517, E4BAEE4AB7834E19BA57C4A1, 1060DB32269548BE895EFA7F, CA265A4F2CAA41DA8F68A35C, 0381775B0B1D4378A8BF289A, 85D4CBF80A8F447790235E7C, 2EA97648294C4D98B3D85EFD, FA4A1042B44B41E0AE768E0C, 97DF04B6B21743BC9615E634, 1B031A4D51634E96BBD3D8DA, BC442280384D4194A66F25D2, EF6E8429A9DF475E9C964A2A, AA419130C5A248DCA3B4A016, 8CF76F8D690C405D9FCC4B8E, E1A1C8A803FB4CA1BA741E57, 195EFB33D08D484489FD721B, DB0DF408F60149008C9644C0, E9F0583912AA42F787751A7F, FB19CB746D6146D9ABD1A1C4, CD8C48EA12DB45FF978FA049, 838A41F3BF1E42BDAA2CE2E6, 2074992AD4E94430B9524FB3, B51507FF41FB4B80831D70E3, 8EA7E4972BB74FDD884370D0, CC2B124ACD024538B20863B8, 31B0AEBA3A0140D2BC9698DC, 0C84054AB3574CC5B8056694, D7434175C1E849D7A8308896, 3DE0F41386A741B2A6FC538B, 1F6A499FE7D94EF498DA9A84, E13972C3F4664A89AE13D5D8, DDF38B8E56394DEBA022F812, DA51BC65490E421AA3D16F02, 0A439BB96CCB4C038EEF44DD, 5DD00428364442C6B8C55DDF, 9F958AB307244896BD8948D2, 21C2A6AB0E164C8CBDF9CF7F, 1702AF57020940DEBF0F9BA0, 3004C07E608644AFB636C0C5, DC39718A2EC74B078A0C5DE9, BCEB22897600469E93762209, 13BC17CE70034FEE97707B21, AAAD219E4FA94CF885472711, 29F84FF3F1E74E49A3EA5101, 1875AE0A77B94DF0921EE457, 3168221266004A33BA35042C, C97603C3A54342799CBD0C03, 5AFEAFB3E8F047B9A2B0DD04, CE7A5A0E6D9A49EA95918E79, 68CB1416AC24491CAA4CE87A, F68654552B254000B6F0BE97, 1255FF9134D246ECBE1DF72C, 6DFD25C0855F482DBEFD8757, 12C3FA28B28146B9A8E3A70A, 5D760AAFB9C34DACBFC2387A, 85B0F928887C44128434974E, E907C350C3E5410FB4A2038D); name = MatchLiveTv; sourceTree = "<group>"; };
|
||||
E068E1D4709041789B1E8F5A = {isa = PBXGroup; children = (73BB032A0DD74458B0E716C6, C186E88B35A0443494AE6E8E, 5751DB993C52460D960B8067, 0E243C00009048F6A778323C, 634AD5C2E53B4C098582284A, B7DE055717014393B3F62D5A, C3C1AC6562ED4A908D7C8B0A, 610A4D03FA384F0287AAF825); name = MatchLiveTvTests; sourceTree = "<group>"; };
|
||||
2165E0C34A8B43F28AC2B21C = {isa = PBXGroup; children = (6FF24BB96E9C40F6A9F6E25A, 8F679C9C03AE47D6A028DBAC); name = Products; sourceTree = "<group>"; };
|
||||
3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; };
|
||||
3B35A518C56047A48825F0F8 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (F7624153A1C84C218B602FFB, D973F2B503854C6AA61A1559, FE2D8C8359CC4DA189DF5DC6, B607A146AE754F6A819FB186, B0D93B493FD0427CA72F5862, 3C223B19BA2149C5B7E5CC5A, 3D119DE97DE5452D903F364C, 8ABF00BDC15C400A9089FC28, 7D2649B9131F4DDA83F68B72, AB71730B7CA24D3E8667BCFF, ADA58448C2344DADAA90F212, 95F7871858C44379A62012BA, 3FBBEF8DFF5E4F5D9E34C860, 91694276C76B438F8B52448F, 51D849AFAC8D4F57BEF05CAB, D771BABE58A3439CA1768F7F, 23C149E317BD416D9CF0B171, 43C57FF23CB347AC99284468, 8661E674C82545C4AEC0A7CE, E133D004CF0A4FF7A51A47B1, 72090ACA64CB4885BE7AB333, D80261301CA740568D8807D2, 9ECD8B486DF84890BD39B108, C217B8AD49D04976A6D7FC7E, 978AAF829F654537AC473F52, 032C790773A848B7A0434C67, A37BC192D6C9433F83FABAA8, 99F1D8A337EE46C8BDD34F60, F024262D0D0F4B759E1A4F14, FE9E0B76650246E490722317, BD642A0BD19D424985FBF1A3, B48421E7BD5D48E499948E2E, C6207D98BB484493BEDD4BBB, E715CA8C60E6439683E1DA0E, 35F5E2E5FB0D4B9FA475ADA4, 6D8B0F1792E440C19F49AD03, CC563D93928C438AB0B78E99, 9343740F4EB94F609A4D1A51, D71C1DFF234742DA81D23AF7, DADD5004759C409F82D4FEE2, 957381C0C3A447E7A3240ACD, 326AEF0FBFE74201BE70D2DD, DCC6F79099C641DF9EA6F36F, CA82A2382C4A45BE88276507, 37E8D25C18014E0C8139D84D, 67A37D0BB28F4BD1872BACA6, 7AA96446B33C4ECCB90DDFD0, 599290557AE4430EA30582D7, D71CB837FE844932AA590D30, 52806453AD4C4E59BA2F05DE, FA601701F8684EE2BFDBBBFD, A85ED43E903E4983B1438D11, 195AF301A0AC4BA58F8EDE95, 8C0225C7F87A4F9A8A0C2919, 7293CD3C1B634333A59FA782, 465980490F2D417E87241EBA, 044E04EEE71D472EA524C555, 52CEBF8B18834CB8B650FE16, 94ADCCD464DD43149D0C0E60, F23C2967ED7140EB8DCB2D2A, 47AD4ADC4ACE4F6DA7AD708C, 8B0A9A9DBAF346199EBA21B3, D826E98233304F5FB27B27C3, 579A3A8D49DF43E7B11942C6, D0BEA6E726C04A95A8CC0018, 15DA64BB40B14C9B899A3729, 4DF6B21EFDD54DF999385511, AEF63662B6B5454F92986710, 78179BA533074F1ABBB7391E, 45E14846E79845CE9C20C5EC, 2049733B63794135A4BFBB53); runOnlyForDeploymentPostprocessing = 0; };
|
||||
729C230D5EB64A76BCD517B4 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (6B75AFC14BBF4E2B97762ECB, 9BA38C8D635F4770A232AE85, 1889CDB168794D81A5B24699, E25CB4FB944141D0AAB57EDF, 6BE3B2533E6A4E3DA32EA972, B88859D9B34E4F289913DDFC, B8CC446DB3AE4BABB4D739D1, 6CCB67439127403180D9C697); runOnlyForDeploymentPostprocessing = 0; };
|
||||
6B4D5383389142E7AE9C4F41 = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = (C44DD19CDB6545A381B61D20, 7C8A2DFFAB6B4CB8AA3E4FE8, 8D479F8873F94073938D737E, 9A894DBC632C4904BC5D9A78, 9246A8532B66443A83535192, 5DA941A5928A4F638223030C); runOnlyForDeploymentPostprocessing = 0; };
|
||||
AA7CFC56BCC040A4AB487E2C = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||
|
||||
@@ -78,6 +78,23 @@ enum L10n {
|
||||
"action.exit": "Esci",
|
||||
"action.login": "Accedi",
|
||||
"action.logout": "Esci",
|
||||
"account.confirm.password": "Conferma password",
|
||||
"account.current.password": "Password attuale",
|
||||
"account.name.label": "Nome",
|
||||
"account.new.password": "Nuova password (min. 8 caratteri)",
|
||||
"account.password.heading": "Cambia password",
|
||||
"account.password.saved": "Password aggiornata",
|
||||
"account.profile.heading": "Profilo",
|
||||
"account.profile.saved": "Profilo aggiornato",
|
||||
"account.role": "Ruolo: %1$@",
|
||||
"account.save.password": "Aggiorna password",
|
||||
"account.save.profile": "Salva profilo",
|
||||
"account.title": "Account",
|
||||
"forgot.password.lead": "Inserisci l'email dell'account: ti invieremo un link per reimpostare la password.",
|
||||
"forgot.password.submit": "Invia link di reset",
|
||||
"forgot.password.success": "Se l'email è registrata, riceverai a breve un link per reimpostare la password.",
|
||||
"forgot.password.title": "Password dimenticata",
|
||||
"login.forgot.password": "Password dimenticata?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Salva",
|
||||
"api.error.decoding": "Risposta del server non valida",
|
||||
@@ -352,6 +369,23 @@ enum L10n {
|
||||
"action.exit": "Exit",
|
||||
"action.login": "Log in",
|
||||
"action.logout": "Log out",
|
||||
"account.confirm.password": "Confirm password",
|
||||
"account.current.password": "Current password",
|
||||
"account.name.label": "Name",
|
||||
"account.new.password": "New password (min. 8 characters)",
|
||||
"account.password.heading": "Change password",
|
||||
"account.password.saved": "Password updated",
|
||||
"account.profile.heading": "Profile",
|
||||
"account.profile.saved": "Profile updated",
|
||||
"account.role": "Role: %1$@",
|
||||
"account.save.password": "Update password",
|
||||
"account.save.profile": "Save profile",
|
||||
"account.title": "Account",
|
||||
"forgot.password.lead": "Enter your account email: we'll send you a link to reset your password.",
|
||||
"forgot.password.submit": "Send reset link",
|
||||
"forgot.password.success": "If the email is registered, you will receive a password reset link shortly.",
|
||||
"forgot.password.title": "Forgot password",
|
||||
"login.forgot.password": "Forgot password?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Save",
|
||||
"api.error.decoding": "Invalid server response",
|
||||
@@ -626,6 +660,23 @@ enum L10n {
|
||||
"action.exit": "Quitter",
|
||||
"action.login": "Connexion",
|
||||
"action.logout": "Déconnexion",
|
||||
"account.confirm.password": "Confirmer le mot de passe",
|
||||
"account.current.password": "Mot de passe actuel",
|
||||
"account.name.label": "Nom",
|
||||
"account.new.password": "Nouveau mot de passe (min. 8 caractères)",
|
||||
"account.password.heading": "Changer le mot de passe",
|
||||
"account.password.saved": "Mot de passe mis à jour",
|
||||
"account.profile.heading": "Profil",
|
||||
"account.profile.saved": "Profil mis à jour",
|
||||
"account.role": "Rôle : %1$@",
|
||||
"account.save.password": "Mettre à jour le mot de passe",
|
||||
"account.save.profile": "Enregistrer le profil",
|
||||
"account.title": "Compte",
|
||||
"forgot.password.lead": "Saisissez l'e-mail du compte : nous vous enverrons un lien pour réinitialiser le mot de passe.",
|
||||
"forgot.password.submit": "Envoyer le lien",
|
||||
"forgot.password.success": "Si l'e-mail est enregistré, vous recevrez bientôt un lien de réinitialisation.",
|
||||
"forgot.password.title": "Mot de passe oublié",
|
||||
"login.forgot.password": "Mot de passe oublié ?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Enregistrer",
|
||||
"api.error.decoding": "Réponse du serveur non valide",
|
||||
@@ -900,6 +951,23 @@ enum L10n {
|
||||
"action.exit": "Beenden",
|
||||
"action.login": "Anmelden",
|
||||
"action.logout": "Abmelden",
|
||||
"account.confirm.password": "Passwort bestätigen",
|
||||
"account.current.password": "Aktuelles Passwort",
|
||||
"account.name.label": "Name",
|
||||
"account.new.password": "Neues Passwort (mind. 8 Zeichen)",
|
||||
"account.password.heading": "Passwort ändern",
|
||||
"account.password.saved": "Passwort aktualisiert",
|
||||
"account.profile.heading": "Profil",
|
||||
"account.profile.saved": "Profil aktualisiert",
|
||||
"account.role": "Rolle: %1$@",
|
||||
"account.save.password": "Passwort aktualisieren",
|
||||
"account.save.profile": "Profil speichern",
|
||||
"account.title": "Konto",
|
||||
"forgot.password.lead": "Gib die E-Mail des Kontos ein: Wir senden dir einen Link zum Zurücksetzen des Passworts.",
|
||||
"forgot.password.submit": "Reset-Link senden",
|
||||
"forgot.password.success": "Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen.",
|
||||
"forgot.password.title": "Passwort vergessen",
|
||||
"login.forgot.password": "Passwort vergessen?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Speichern",
|
||||
"api.error.decoding": "Ungültige Serverantwort",
|
||||
@@ -1174,6 +1242,23 @@ enum L10n {
|
||||
"action.exit": "Salir",
|
||||
"action.login": "Acceder",
|
||||
"action.logout": "Salir",
|
||||
"account.confirm.password": "Confirmar contraseña",
|
||||
"account.current.password": "Contraseña actual",
|
||||
"account.name.label": "Nombre",
|
||||
"account.new.password": "Nueva contraseña (mín. 8 caracteres)",
|
||||
"account.password.heading": "Cambiar contraseña",
|
||||
"account.password.saved": "Contraseña actualizada",
|
||||
"account.profile.heading": "Perfil",
|
||||
"account.profile.saved": "Perfil actualizado",
|
||||
"account.role": "Rol: %1$@",
|
||||
"account.save.password": "Actualizar contraseña",
|
||||
"account.save.profile": "Guardar perfil",
|
||||
"account.title": "Cuenta",
|
||||
"forgot.password.lead": "Introduce el email de la cuenta: te enviaremos un enlace para restablecer la contraseña.",
|
||||
"forgot.password.submit": "Enviar enlace",
|
||||
"forgot.password.success": "Si el email está registrado, recibirás en breve un enlace para restablecer la contraseña.",
|
||||
"forgot.password.title": "Contraseña olvidada",
|
||||
"login.forgot.password": "¿Olvidaste la contraseña?",
|
||||
"action.ok": "OK",
|
||||
"action.save": "Guardar",
|
||||
"api.error.decoding": "Respuesta del servidor no válida",
|
||||
|
||||
@@ -13,6 +13,34 @@ struct RefreshRequest: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct ForgotPasswordRequest: Encodable {
|
||||
let email: String
|
||||
}
|
||||
|
||||
struct ForgotPasswordResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct UpdateAccountRequest: Encodable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct ChangePasswordRequest: Encodable {
|
||||
let currentPassword: String
|
||||
let password: String
|
||||
let passwordConfirmation: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case currentPassword = "current_password"
|
||||
case password
|
||||
case passwordConfirmation = "password_confirmation"
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let user: UserDto
|
||||
let accessToken: String
|
||||
|
||||
@@ -73,6 +73,33 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
try await postVoid("auth/logout")
|
||||
}
|
||||
|
||||
func forgotPassword(email: String) async throws -> ForgotPasswordResponse {
|
||||
try await post("auth/password/forgot", body: ForgotPasswordRequest(email: email))
|
||||
}
|
||||
|
||||
func account() async throws -> UserDto {
|
||||
try await get("account")
|
||||
}
|
||||
|
||||
func updateAccount(name: String) async throws -> UserDto {
|
||||
try await patch("account", body: UpdateAccountRequest(name: name))
|
||||
}
|
||||
|
||||
func changePassword(
|
||||
currentPassword: String,
|
||||
password: String,
|
||||
passwordConfirmation: String
|
||||
) async throws -> MessageResponse {
|
||||
try await patch(
|
||||
"account/password",
|
||||
body: ChangePasswordRequest(
|
||||
currentPassword: currentPassword,
|
||||
password: password,
|
||||
passwordConfirmation: passwordConfirmation
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Sports & Teams
|
||||
|
||||
func sports() async throws -> [SportDto] {
|
||||
@@ -267,6 +294,7 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
return path.hasSuffix("/auth/login")
|
||||
|| path.hasSuffix("/auth/refresh")
|
||||
|| path.hasSuffix("/auth/register")
|
||||
|| path.hasSuffix("/auth/password/forgot")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,31 @@ final class AuthRepository {
|
||||
return session
|
||||
}
|
||||
|
||||
func forgotPassword(email: String) async throws -> String {
|
||||
try await api.forgotPassword(email: email.trimmingCharacters(in: .whitespacesAndNewlines)).message
|
||||
}
|
||||
|
||||
func fetchAccount() async throws -> User {
|
||||
try await api.account().toDomain()
|
||||
}
|
||||
|
||||
func updateName(_ name: String) async throws -> User {
|
||||
let user = try await api.updateAccount(name: name.trimmingCharacters(in: .whitespacesAndNewlines)).toDomain()
|
||||
if var stored = tokenStore.session {
|
||||
stored = StoredSession(user: user, accessToken: stored.accessToken, refreshToken: stored.refreshToken)
|
||||
tokenStore.saveSession(stored)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
func changePassword(currentPassword: String, password: String, passwordConfirmation: String) async throws {
|
||||
_ = try await api.changePassword(
|
||||
currentPassword: currentPassword,
|
||||
password: password,
|
||||
passwordConfirmation: passwordConfirmation
|
||||
)
|
||||
}
|
||||
|
||||
func logout() async {
|
||||
try? await api.logout()
|
||||
tokenStore.clear()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Profilo RTMP allineato a MediaMTX slate + YoutubeRelay (copy A/V).
|
||||
/// Audio: AAC 48 kHz mono 128 kbps.
|
||||
struct BroadcastConfig: Sendable {
|
||||
let rtmpUrl: String
|
||||
var width: Int = 1280
|
||||
@@ -11,6 +13,9 @@ struct BroadcastConfig: Sendable {
|
||||
var portrait: Bool = false
|
||||
var maxReconnectAttempts: Int = 10
|
||||
var reconnectDelayMs: Int = 5_000
|
||||
|
||||
static let audioSampleRateHz: Float64 = 48_000
|
||||
static let audioChannels: UInt32 = 1
|
||||
}
|
||||
|
||||
enum BroadcastPhase: String, Sendable {
|
||||
|
||||
@@ -214,6 +214,14 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
if let microphone = AVCaptureDevice.default(for: .audio) {
|
||||
try await mixer.attachAudio(microphone, track: 0)
|
||||
}
|
||||
// AAC 48 kHz mono — allineato ad Android, slate MediaMTX e YoutubeRelay (-c:a copy).
|
||||
try await mixer.setAudioMixerSettings(
|
||||
AudioMixerSettings(
|
||||
sampleRate: BroadcastConfig.audioSampleRateHz,
|
||||
channels: BroadcastConfig.audioChannels,
|
||||
tracks: [0: AudioMixerTrackSettings(downmix: true, channelMap: [0])]
|
||||
)
|
||||
)
|
||||
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
|
||||
try await mixer.attachVideo(camera, track: 0) { videoUnit in
|
||||
videoUnit.isVideoMirrored = false
|
||||
@@ -355,10 +363,10 @@ final class LiveBroadcastEngine: ObservableObject {
|
||||
var audioSettings = await stream.audioSettings
|
||||
audioSettings = AudioCodecSettings(
|
||||
bitRate: config.audioBitrate,
|
||||
downmix: audioSettings.downmix,
|
||||
channelMap: audioSettings.channelMap,
|
||||
sampleRate: 48_000,
|
||||
format: audioSettings.format
|
||||
downmix: true,
|
||||
channelMap: [0],
|
||||
sampleRate: BroadcastConfig.audioSampleRateHz,
|
||||
format: .aac
|
||||
)
|
||||
try await stream.setAudioSettings(audioSettings)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import SwiftUI
|
||||
|
||||
struct AccountScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let onBack: () -> Void
|
||||
let onLoggedOut: () -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var role = ""
|
||||
@State private var name = ""
|
||||
@State private var currentPassword = ""
|
||||
@State private var newPassword = ""
|
||||
@State private var confirmPassword = ""
|
||||
@State private var passwordVisible = false
|
||||
@State private var loadingProfile = true
|
||||
@State private var savingProfile = false
|
||||
@State private var savingPassword = false
|
||||
@State private var loggingOut = false
|
||||
@State private var profileMessage: String?
|
||||
@State private var profileError: String?
|
||||
@State private var passwordMessage: String?
|
||||
@State private var passwordError: String?
|
||||
@State private var languageTick = 0
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
topBar: {
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
Text(L10n.t("account.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
},
|
||||
content: {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text(L10n.t("account.profile.heading"))
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
disabledField(title: L10n.t("login.email"), value: email)
|
||||
if !role.isEmpty {
|
||||
Text(L10n.t("account.role", role))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
MatchAccountTextField(title: L10n.t("account.name.label"), text: $name)
|
||||
|
||||
if let profileError {
|
||||
Text(profileError).foregroundStyle(MatchColors.primaryRed)
|
||||
}
|
||||
if let profileMessage {
|
||||
Text(profileMessage).foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
|
||||
MatchPrimaryButton(
|
||||
label: L10n.t("account.save.profile"),
|
||||
action: saveProfile,
|
||||
enabled: !loadingProfile && !name.trimmingCharacters(in: .whitespaces).isEmpty && !savingProfile,
|
||||
loading: savingProfile
|
||||
)
|
||||
|
||||
Text(L10n.t("account.password.heading"))
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.top, 16)
|
||||
|
||||
MatchAccountSecureField(
|
||||
title: L10n.t("account.current.password"),
|
||||
text: $currentPassword,
|
||||
visible: $passwordVisible
|
||||
)
|
||||
MatchAccountSecureField(
|
||||
title: L10n.t("account.new.password"),
|
||||
text: $newPassword,
|
||||
visible: $passwordVisible
|
||||
)
|
||||
MatchAccountSecureField(
|
||||
title: L10n.t("account.confirm.password"),
|
||||
text: $confirmPassword,
|
||||
visible: $passwordVisible
|
||||
)
|
||||
|
||||
if let passwordError {
|
||||
Text(passwordError).foregroundStyle(MatchColors.primaryRed)
|
||||
}
|
||||
if let passwordMessage {
|
||||
Text(passwordMessage).foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
|
||||
MatchPrimaryButton(
|
||||
label: L10n.t("account.save.password"),
|
||||
action: savePassword,
|
||||
enabled: !currentPassword.isEmpty && !newPassword.isEmpty && !confirmPassword.isEmpty && !savingPassword,
|
||||
loading: savingPassword
|
||||
)
|
||||
|
||||
MatchSecondaryButton(
|
||||
label: L10n.t("action.logout"),
|
||||
action: logout,
|
||||
enabled: !loggingOut
|
||||
)
|
||||
.padding(.top, 24)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
)
|
||||
.task { await loadAccount() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
}
|
||||
|
||||
private func disabledField(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
Text(value.isEmpty ? "—" : value)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAccount() async {
|
||||
loadingProfile = true
|
||||
do {
|
||||
let user = try await container.authRepository.fetchAccount()
|
||||
email = user.email
|
||||
name = user.name
|
||||
role = user.role
|
||||
} catch {
|
||||
profileError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||
}
|
||||
loadingProfile = false
|
||||
}
|
||||
|
||||
private func saveProfile() {
|
||||
savingProfile = true
|
||||
profileError = nil
|
||||
profileMessage = nil
|
||||
Task {
|
||||
do {
|
||||
let user = try await container.authRepository.updateName(name)
|
||||
name = user.name
|
||||
profileMessage = L10n.t("account.profile.saved")
|
||||
} catch {
|
||||
profileError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||
}
|
||||
savingProfile = false
|
||||
}
|
||||
}
|
||||
|
||||
private func savePassword() {
|
||||
savingPassword = true
|
||||
passwordError = nil
|
||||
passwordMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await container.authRepository.changePassword(
|
||||
currentPassword: currentPassword,
|
||||
password: newPassword,
|
||||
passwordConfirmation: confirmPassword
|
||||
)
|
||||
currentPassword = ""
|
||||
newPassword = ""
|
||||
confirmPassword = ""
|
||||
passwordMessage = L10n.t("account.password.saved")
|
||||
} catch {
|
||||
passwordError = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||
}
|
||||
savingPassword = false
|
||||
}
|
||||
}
|
||||
|
||||
private func logout() {
|
||||
loggingOut = true
|
||||
Task {
|
||||
await container.authRepository.logout()
|
||||
onLoggedOut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchAccountTextField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
TextField("", text: $text)
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MatchAccountSecureField: View {
|
||||
let title: String
|
||||
@Binding var text: String
|
||||
@Binding var visible: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
HStack {
|
||||
Group {
|
||||
if visible {
|
||||
TextField("", text: $text)
|
||||
} else {
|
||||
SecureField("", text: $text)
|
||||
}
|
||||
}
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Button(action: { visible.toggle() }) {
|
||||
Image(systemName: visible ? "eye.slash" : "eye")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ForgotPasswordScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let onBack: () -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var loading = false
|
||||
@State private var error: String?
|
||||
@State private var success = false
|
||||
@State private var languageTick = 0
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
topBar: {
|
||||
HStack {
|
||||
Button(action: onBack) {
|
||||
Image(systemName: "chevron.left")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
Text(L10n.t("forgot.password.title"))
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.foregroundStyle(.white)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
},
|
||||
content: {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
Text(L10n.t("forgot.password.lead"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(L10n.t("login.email"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
TextField("", text: $email)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.emailAddress)
|
||||
.autocorrectionDisabled()
|
||||
.disabled(success)
|
||||
.padding(12)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline))
|
||||
}
|
||||
|
||||
if let error {
|
||||
Text(error)
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
if success {
|
||||
Text(L10n.t("forgot.password.success"))
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
MatchPrimaryButton(
|
||||
label: L10n.t("forgot.password.submit"),
|
||||
action: submit,
|
||||
enabled: !email.trimmingCharacters(in: .whitespaces).isEmpty && !loading && !success,
|
||||
loading: loading
|
||||
)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
}
|
||||
|
||||
private func submit() {
|
||||
loading = true
|
||||
error = nil
|
||||
Task {
|
||||
do {
|
||||
_ = try await container.authRepository.forgotPassword(email: email)
|
||||
success = true
|
||||
} catch {
|
||||
self.error = UserFacingError.message(for: error) ?? L10n.t("common.error.generic")
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct LoginScreen: View {
|
||||
@ObservedObject var container: AppContainer
|
||||
let onLoggedIn: () -> Void
|
||||
let onForgotPassword: () -> Void
|
||||
|
||||
@State private var email = ""
|
||||
@State private var password = ""
|
||||
@@ -37,6 +38,16 @@ struct LoginScreen: View {
|
||||
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
|
||||
}
|
||||
.padding(.top, 32)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button(action: onForgotPassword) {
|
||||
Text(L10n.t("login.forgot.password"))
|
||||
.font(MatchTypography.bodyMedium)
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
.underline()
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
if let error {
|
||||
Text(error)
|
||||
.foregroundStyle(MatchColors.primaryRed)
|
||||
|
||||
@@ -5,7 +5,7 @@ struct MatchesScreen: View {
|
||||
var refreshToken: Int = 0
|
||||
let onOpenSetup: (String) -> Void
|
||||
let onOpenBroadcast: (String) -> Void
|
||||
let onLogout: () -> Void
|
||||
let onOpenAccount: () -> Void
|
||||
|
||||
@State private var loading = true
|
||||
@State private var refreshing = false
|
||||
@@ -37,15 +37,12 @@ struct MatchesScreen: View {
|
||||
}
|
||||
.accessibilityLabel(L10n.t("language.label"))
|
||||
Button {
|
||||
Task {
|
||||
await container.authRepository.logout()
|
||||
onLogout()
|
||||
}
|
||||
onOpenAccount()
|
||||
} label: {
|
||||
Image(systemName: "rectangle.portrait.and.arrow.right")
|
||||
Image(systemName: "person.crop.circle")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.accessibilityLabel(L10n.t("action.logout"))
|
||||
.accessibilityLabel(L10n.t("account.title"))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
@@ -27,7 +27,16 @@ struct AppNavHost: View {
|
||||
case .splash:
|
||||
EmptyView()
|
||||
case .login:
|
||||
LoginScreen(container: container) { path = [.matches] }
|
||||
LoginScreen(
|
||||
container: container,
|
||||
onLoggedIn: { path = [.matches] },
|
||||
onForgotPassword: { path.append(.forgotPassword) }
|
||||
)
|
||||
case .forgotPassword:
|
||||
ForgotPasswordScreen(
|
||||
container: container,
|
||||
onBack: { path.removeLast() }
|
||||
)
|
||||
case .matches:
|
||||
MatchesScreen(
|
||||
container: container,
|
||||
@@ -38,9 +47,15 @@ struct AppNavHost: View {
|
||||
onOpenBroadcast: { sessionId in
|
||||
broadcastRoute = BroadcastRoute(sessionId: sessionId)
|
||||
},
|
||||
onLogout: { path = [.login] }
|
||||
onOpenAccount: { path.append(.account) }
|
||||
)
|
||||
.lockPortraitOrientation()
|
||||
case .account:
|
||||
AccountScreen(
|
||||
container: container,
|
||||
onBack: { path.removeLast() },
|
||||
onLoggedOut: { path = [.login] }
|
||||
)
|
||||
case .setup, .broadcast:
|
||||
EmptyView()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import Foundation
|
||||
enum Routes: Hashable {
|
||||
case splash
|
||||
case login
|
||||
case forgotPassword
|
||||
case matches
|
||||
case account
|
||||
case setup(matchId: String, step: Int)
|
||||
case broadcast(sessionId: String)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,17 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then
|
||||
--exclude '.git' \
|
||||
--exclude 'native/android/build' \
|
||||
--exclude 'native/android/.gradle' \
|
||||
--exclude 'native/android/app/build' \
|
||||
--exclude 'native/android/dist' \
|
||||
--exclude 'native/android/keystore' \
|
||||
--exclude 'native/android/keystore.properties' \
|
||||
--exclude 'native/android/local.properties' \
|
||||
--exclude 'backend/log' \
|
||||
--exclude 'backend/tmp' \
|
||||
--exclude 'backend/.bundle' \
|
||||
--exclude 'infra/.env' \
|
||||
--exclude 'infra/garage/garage.prod.toml' \
|
||||
--exclude 'infra/garage/prod-credentials.env' \
|
||||
--exclude 'log/' \
|
||||
--exclude 'node_modules' \
|
||||
"$SRC/" "$SERVER:$DEST/"
|
||||
@@ -24,7 +31,10 @@ else
|
||||
echo "rsync non disponibile sul server, uso tar+scp..."
|
||||
tar -C "$SRC" -czf /tmp/matchlivetv-deploy.tar.gz \
|
||||
--exclude='./native/android/build' --exclude='./native/android/.gradle' \
|
||||
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' --exclude='./.git' .
|
||||
--exclude='./native/android/app/build' --exclude='./native/android/dist' \
|
||||
--exclude='./native/android/keystore' --exclude='./native/android/keystore.properties' \
|
||||
--exclude='./backend/log' --exclude='./backend/tmp' --exclude='./infra/.env' \
|
||||
--exclude='./infra/garage/garage.prod.toml' --exclude='./.git' .
|
||||
scp /tmp/matchlivetv-deploy.tar.gz "$SERVER:~/matchlivetv-deploy.tar.gz"
|
||||
ssh "$SERVER" "mkdir -p $DEST && tar -xzf ~/matchlivetv-deploy.tar.gz -C $DEST"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user