Unifica admin società/squadre e YouTube Full con fallback MLTV.

Premium Full senza canale società usa il canale piattaforma come Light;
in app restano solo Match Live TV e YouTube Live.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 22:38:28 +02:00
parent 82ff1972a0
commit 9b40deeb61
14 changed files with 125 additions and 144 deletions

View File

@@ -3,13 +3,14 @@ module Admin
before_action :set_club, only: %i[show grant_comped revoke_comped]
def index
@clubs = Club.includes(:subscription, subscription: :plan, subscription: :admin_comped_by)
@clubs = Club.includes(:subscription, :teams, subscription: :plan, subscription: :admin_comped_by)
.order(:name)
end
def show
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.includes(:youtube_credential).order(:name)
end
def grant_comped

View File

@@ -1,7 +1,7 @@
module Admin
class TeamsController < Admin::BaseController
def index
@teams = Team.all.order(:name)
redirect_to admin_clubs_path, status: :moved_permanently
end
def show

View File

@@ -6,19 +6,20 @@ module Youtube
def initialize(team)
@team = team
@status = TeamStatus.new(team)
@mode = team.entitlements.plan.youtube_mode
end
def resolve
if @mode == "matchlivetv_light" && PlatformCredential.configured?
if @status.uses_platform_channel?
PlatformCredential.new
else
elsif @mode == "team"
@team.youtube_credential
end
end
def uses_platform_channel?
@mode == "matchlivetv_light" && PlatformCredential.configured?
@status.uses_platform_channel?
end
end
end

View File

@@ -15,7 +15,7 @@ module Youtube
when "matchlivetv_light"
PlatformCredential.configured?
when "team"
@team.youtube_credential.present?
@team.youtube_credential.present? || PlatformCredential.configured?
else
false
end
@@ -26,7 +26,15 @@ module Youtube
end
def uses_platform_channel?
@mode == "matchlivetv_light" && PlatformCredential.configured?
return false unless @ent.youtube_enabled?
if @mode == "matchlivetv_light"
PlatformCredential.configured?
elsif @mode == "team"
@team.youtube_credential.blank? && PlatformCredential.configured?
else
false
end
end
def channel_title
@@ -38,7 +46,7 @@ module Youtube
end
def needs_team_oauth?
@mode == "team" && @ent.premium_full?
@mode == "team" && @ent.premium_full? && @team.youtube_credential.blank? && !PlatformCredential.configured?
end
end
end

View File

@@ -1,6 +1,7 @@
<h2>Società</h2>
<h2>Società e squadre</h2>
<p style="color:#888;margin-bottom:16px">
Gestisci abbonamenti omaggio (sponsor / promozioni). Per pagamenti Stripe usa <%= link_to "Fatturazione", admin_billing_path %>.
Abbonamenti omaggio (sponsor / promozioni) e stato YouTube per squadra.
Pagamenti Stripe: <%= link_to "Fatturazione", admin_billing_path %>.
</p>
<table class="admin-table">
@@ -8,6 +9,7 @@
<tr>
<th>Società</th>
<th>Piano</th>
<th>Squadre</th>
<th>Omaggio</th>
<th>Stripe</th>
<th></th>
@@ -19,6 +21,7 @@
<tr>
<td><strong><%= club.name %></strong></td>
<td><%= sub&.plan&.name || "Free" %></td>
<td><%= club.teams.size %></td>
<td>
<% if sub&.admin_comped? %>
<span style="color:#ffb74d">Sì</span>

View File

@@ -1,16 +1,52 @@
<p style="margin-bottom:16px"><%= link_to "← Tutte le società", admin_clubs_path %></p>
<p style="margin-bottom:16px"><%= link_to "← Società e squadre", admin_clubs_path %></p>
<h2><%= @club.name %></h2>
<p style="color:#888">
Sport: <%= @club.sport %>
· <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @club.id) %>
· <%= link_to "Canale YouTube piattaforma", admin_youtube_platform_path %>
</p>
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
<h3 style="font-size:1rem;margin-top:24px">Squadre</h3>
<ul>
<% @club.teams.order(:name).each do |team| %>
<li><%= link_to team.name, admin_team_path(team) %></li>
<h3 style="font-size:1rem;margin-top:28px">Squadre</h3>
<% if @teams.empty? %>
<p class="muted">Nessuna squadra registrata.</p>
<% else %>
<table class="admin-table">
<thead>
<tr>
<th>Squadra</th>
<th>Sport</th>
<th>YouTube</th>
<th></th>
</tr>
</thead>
<tbody>
<% @teams.each do |team| %>
<% yt = Youtube::TeamStatus.new(team) %>
<tr>
<td><strong><%= team.name %></strong></td>
<td><%= team.sport %></td>
<td>
<% if yt.selectable? %>
<% if yt.uses_platform_channel? %>
<span style="color:#81c784">Match Live TV</span>
<% if team.youtube_credential.blank? && team.entitlements.premium_full? %>
<span class="muted"> (default Full)</span>
<% end %>
</ul>
<% else %>
<span style="color:#81c784">✓ <%= yt.channel_title.presence || "Canale società" %></span>
<% end %>
<% elsif team.entitlements.youtube_enabled? %>
<span class="muted">In configurazione</span>
<% else %>
<% end %>
</td>
<td><%= link_to "Partite e dettagli", admin_team_path(team) %></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>

View File

@@ -123,7 +123,7 @@
<% end %>
</ul>
<% if @stats[:teams_count] > @teams.size %>
<p class="kpi-sub" style="margin-top:0.75rem"><%= link_to "Vedi tutte (#{@stats[:teams_count]})", admin_teams_path %></p>
<p class="kpi-sub" style="margin-top:0.75rem"><%= link_to "Vedi società (#{@stats[:teams_count]} squadre)", admin_clubs_path %></p>
<% end %>
</div>
</section>

View File

@@ -1,21 +0,0 @@
<h2>Teams</h2>
<table class="admin-table">
<thead><tr><th>Nome</th><th>Società</th><th>Sport</th><th>YouTube</th><th>Fatture</th></tr></thead>
<tbody>
<% @teams.each do |t| %>
<tr>
<td><%= link_to t.name, admin_team_path(t) %></td>
<td><%= t.club&.name || "—" %></td>
<td><%= t.sport %></td>
<td><%= t.youtube_credential.present? ? "✓" : "—" %></td>
<td>
<% if t.club %>
<%= link_to "Fatturazione", admin_billing_path(club_id: t.club.id) %>
<% else %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -1,14 +1,32 @@
<% if @team.club %>
<p style="margin-bottom:16px"><%= link_to "← #{@team.club.name}", admin_club_path(@team.club) %></p>
<% end %>
<h2><%= @team.name %></h2>
<p>Sport: <%= @team.sport %></p>
<% if @team.club %>
<p>Società: <%= @team.club.name %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %></p>
<% end %>
<% yt = Youtube::TeamStatus.new(@team) %>
<p>YouTube: <%= yt.channel_title || "—" %> · <%= yt.selectable? ? "pronto" : "non pronto" %></p>
<% if @team.entitlements.plan.youtube_mode == "team" && @team.youtube_credential.blank? %>
<p><%= link_to "Collega (da account titolare sul sito)", public_team_details_path(@team) %></p>
<p>
YouTube:
<% if yt.selectable? %>
<strong><%= yt.channel_title || "—" %></strong>
<% if yt.uses_platform_channel? %>
(canale piattaforma<%= @team.entitlements.premium_full? && @team.youtube_credential.blank? ? ", default senza OAuth società" : "" %>)
<% else %>
(canale società)
<% end %>
<% else %>
non pronto
<% end %>
</p>
<% if @team.entitlements.premium_full? && @team.youtube_credential.blank? %>
<p class="muted">
Senza canale collegato, lapp usa il canale Match Live TV.
<%= link_to "Collega canale (sito pubblico)", public_team_details_path(@team) %>.
</p>
<% end %>
<p><%= link_to "Token canale Match Live TV (Light)", admin_youtube_platform_path %></p>
<h3>Partite</h3>
<ul>

View File

@@ -16,8 +16,7 @@
<nav class="admin-nav">
<% if admin_logged_in? %>
<%= link_to "Dashboard", admin_root_path, class: ("active" if controller_name == "dashboard") %>
<%= link_to "Teams", admin_teams_path, class: ("active" if controller_name == "teams") %>
<%= link_to "Società", admin_clubs_path, class: ("active" if controller_name == "clubs") %>
<%= link_to "Società e squadre", admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams])) %>
<%= link_to "Fatturazione", admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
<%= link_to "YouTube", admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
<%= link_to "Sessions", admin_sessions_path, class: ("active" if controller_name == "sessions") %>

View File

@@ -28,13 +28,18 @@
<%= button_to "Scollega canale", public_team_youtube_disconnect_path(team),
method: :delete,
class: "btn btn-secondary",
form: { data: { turbo_confirm: "Scollegare il canale YouTube? Le dirette future richiederanno un nuovo collegamento." } } %>
form: { data: { turbo_confirm: "Scollegare il canale YouTube? Le dirette useranno il canale Match Live TV finché non ricolleghi il tuo." } } %>
<% else %>
<p class="muted">
Collega il canale YouTube della società per trasmettere dallapp con la piattaforma «YouTube Live».
<p>
Senza canale collegato, le dirette YouTube dallapp vanno sul canale ufficiale
<strong>Match Live TV</strong>.
Puoi collegare il canale della società quando vuoi per trasmettere sul tuo profilo.
</p>
<% if yt.selectable? %>
<p class="muted">YouTube pronto in app (canale Match Live TV).</p>
<% end %>
<% if ENV["YOUTUBE_CLIENT_ID"].present? %>
<%= link_to "Collega canale YouTube", public_team_youtube_connect_path(team), class: "btn btn-primary" %>
<%= link_to "Collega il tuo canale YouTube", public_team_youtube_connect_path(team), class: "btn btn-primary" %>
<% else %>
<p class="muted">OAuth YouTube non ancora configurato sul server.</p>
<% end %>

View File

@@ -67,7 +67,7 @@ Rails.application.routes.draw do
get "metrics", to: "dashboard#metrics"
get "billing", to: "billing#index", as: :billing
post "billing/payments/:payment_id/attach_pdf", to: "billing#attach_pdf", as: :billing_payment_attach_pdf
resources :teams, only: %i[index show]
resources :teams, only: %i[show]
resources :clubs, only: %i[index show] do
member do
post :grant_comped

View File

@@ -1,13 +1,23 @@
# Diretta YouTube dallapp (invito staff)
Percorso per il **responsabile trasmissione** invitato via email: accede in app, collega **il proprio** canale YouTube e avvia la live.
Percorso per il **responsabile trasmissione** invitato via email: accede in app e avvia la live su YouTube.
## Prerequisiti (società)
- Piano **Premium Full** attivo
- Piano **Premium Light** o **Premium Full** attivo
- `YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET` configurati sul server
- Partita programmata per la squadra
### Canale usato
| Piano | Canale YouTube in diretta |
|-------|--------------------------|
| Premium Light | Sempre **Match Live TV** (nessun OAuth società) |
| Premium Full, canale collegato | Canale **della società** |
| Premium Full, canale non collegato | **Match Live TV** (default, come Light) |
Il collegamento del canale società è **opzionale** su Full: si fa dal sito (dettaglio squadra) o in futuro da app; finché non cè OAuth, lapp trasmette sul canale piattaforma.
## 1. Invito
1. Il titolare società → **Responsabili trasmissione** → invita `tua@email.it`
@@ -17,15 +27,12 @@ Percorso per il **responsabile trasmissione** invitato via email: accede in app,
API: `GET /api/v1/invitations/:token`, `POST /api/v1/invitations/:token/accept`
## 2. Collega canale YouTube (tuo)
## 2. Collega canale YouTube (opzionale, Premium Full)
1. Nellapp: nuova diretta → passo **Piattaforma**
2. Tocca **YouTube Live** → si apre Google OAuth
3. **Avanzate** → continua (se app OAuth in verifica)
4. Scegli laccount del **tuo** canale → Consenti
5. Torna allapp → messaggio «Canale collegato»
Solo se la società vuole il **proprio** canale invece di Match Live TV:
Il token OAuth è salvato sulla **squadra**; chi collega deve essere `can_stream` (responsabile trasmissione o titolare).
1. Sul sito: dettaglio squadra → **Collega il tuo canale YouTube**
2. Oppure (se abilitato) OAuth da app al tocco YouTube quando non cè ancora canale piattaforma
## 3. Scegli la partita
@@ -38,9 +45,9 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
## 4. Avvia diretta
1. Nel wizard, seleziona **YouTube Live** (dopo il collegamento canale)
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 inolvia su YouTube con la stream key della live creata via API
3. Il telefono invia RTMP a MediaMTX; il server inoltra su YouTube
## Sviluppo locale
@@ -53,7 +60,7 @@ Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmiss
| Problema | Soluzione |
|----------|-----------|
| Nessuna squadra in app | Accetta invito con email corretta |
| YouTube disabilitato | Premium Full + collegamento canale |
| «Collega canale» | Tocca YouTube Live per OAuth da app |
| 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 | Verifica relay ffmpeg / `stream_key` in sessione |

View File

@@ -1,8 +1,6 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart';
import '../../providers/session_provider.dart';
import '../../shared/api_client.dart';
@@ -75,62 +73,17 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
}
}
Future<void> _connectYoutube(Team team) async {
try {
final url = await ref.read(apiClientProvider).youtubeAuthorizeUrl(team.id);
final uri = Uri.parse(url);
if (!await canLaunchUrl(uri)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossibile aprire il browser per Google')),
);
}
return;
}
await launchUrl(uri, mode: LaunchMode.externalApplication);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Completa il consenso Google, poi torna qui e seleziona YouTube Live.',
),
duration: Duration(seconds: 5),
),
);
}
} on DioException catch (e) {
final apiErr = ApiException.fromDio(e);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(apiErr?.message ?? 'Errore collegamento YouTube')),
);
}
}
}
void _onYoutubeTap(Team? team) {
if (team == null) return;
if (team.isYoutubeReady) {
setState(() => _platform = 'youtube');
return;
}
if (team.canUseYoutube && team.youtubeMode == 'team' && team.canConnectYoutube) {
_connectYoutube(team);
return;
}
if (team.canUseYoutube && team.youtubeMode == 'team') {
showPremiumRequiredDialog(
context,
message: 'Collega il canale YouTube (Premium Full) per trasmettere sul tuo canale.',
billingUrl: team.staffManageUrl ?? team.billingUrl,
);
return;
}
if (team.canUseYoutube && team.youtubeMode == 'matchlivetv_light') {
if (team.canUseYoutube) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'YouTube sul canale Match Live TV non è ancora attivo. Riprova più tardi o usa Match Live TV.',
'YouTube non è ancora disponibile. Riprova più tardi o usa Match Live TV sul sito.',
),
),
);
@@ -152,23 +105,12 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
if (team.youtubeUsesPlatformChannel) {
return label != null ? 'Canale $label' : 'Canale Match Live TV';
}
return label != null ? 'Canale $label' : 'Canale collegato';
return label != null ? 'Canale $label' : 'Canale società collegato';
}
if (team.youtubeMode == 'matchlivetv_light') {
if (team.youtubeMode == 'matchlivetv_light' || team.youtubeMode == 'team') {
return 'Canale Match Live TV (in attivazione)';
}
if (team.canConnectYoutube) {
return 'Tocca per collegare il tuo canale YouTube';
}
return 'Collega canale (Premium Full)';
}
void _onExternalPremiumTap(String platform, Team? team) {
showPremiumRequiredDialog(
context,
message: '$platform è disponibile con Premium (in arrivo).',
billingUrl: team?.billingUrl,
);
return 'In attivazione';
}
@override
@@ -221,24 +163,6 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
badge: team?.canUseYoutube == true ? null : 'Premium',
onTap: () => _onYoutubeTap(team),
),
const SizedBox(height: 8),
_PlatformCard(
title: 'Facebook Live',
subtitle: 'Premium — presto disponibile',
selected: false,
enabled: false,
badge: 'Premium',
onTap: () => _onExternalPremiumTap('Facebook Live', team),
),
const SizedBox(height: 8),
_PlatformCard(
title: 'Twitch',
subtitle: 'Premium — presto disponibile',
selected: false,
enabled: false,
badge: 'Premium',
onTap: () => _onExternalPremiumTap('Twitch', team),
),
const SizedBox(height: 24),
Text('Privacy', style: theme.textTheme.titleLarge),
const SizedBox(height: 12),