diff --git a/backend/app/controllers/admin/youtube_controller.rb b/backend/app/controllers/admin/youtube_controller.rb
new file mode 100644
index 0000000..c76e14e
--- /dev/null
+++ b/backend/app/controllers/admin/youtube_controller.rb
@@ -0,0 +1,13 @@
+module Admin
+ class YoutubeController < Admin::BaseController
+ def platform
+ if ENV["YOUTUBE_CLIENT_ID"].blank?
+ redirect_to admin_root_path, alert: "Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env"
+ return
+ end
+
+ state = Youtube::OauthState.for_platform(admin_id: current_admin_account.id)
+ redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
+ end
+ end
+end
diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb
index 39d1c27..22c445f 100644
--- a/backend/app/controllers/api/v1/teams_controller.rb
+++ b/backend/app/controllers/api/v1/teams_controller.rb
@@ -70,6 +70,7 @@ module Api
def team_json(team, detail: false)
ent = team.entitlements
+ yt = Youtube::TeamStatus.new(team)
data = {
id: team.id,
name: team.name,
@@ -79,7 +80,10 @@ module Api
secondary_color: team.effective_secondary_color,
club_id: team.club_id,
club_name: team.club.name,
- youtube_connected: team.youtube_credential.present?,
+ youtube_connected: yt.connected?,
+ youtube_selectable: yt.selectable?,
+ youtube_channel_title: yt.channel_title,
+ youtube_uses_platform_channel: yt.uses_platform_channel?,
plan_slug: ent.plan.slug,
plan_name: ent.plan.name,
premium_active: ent.premium_active?,
diff --git a/backend/app/controllers/api/v1/youtube_controller.rb b/backend/app/controllers/api/v1/youtube_controller.rb
index 507787a..848c68f 100644
--- a/backend/app/controllers/api/v1/youtube_controller.rb
+++ b/backend/app/controllers/api/v1/youtube_controller.rb
@@ -1,25 +1,70 @@
module Api
module V1
class YoutubeController < BaseController
+ include ActionController::Cookies
+
+ skip_before_action :authenticate_request!, only: :callback
+
def authorize
team = current_user.manageable_teams.find(params[:team_id])
team.entitlements.assert_can_connect_youtube!
- url = Youtube::OauthUrl.build(team_id: team.id, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
+ state = Youtube::OauthState.for_team(team_id: team.id, user_id: current_user.id)
+ url = Youtube::OauthUrl.build(state: state, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
render json: { authorization_url: url }
end
def callback
- team = current_user.manageable_teams.find(params[:state])
+ return redirect_to_oauth_error("Codice OAuth mancante") if params[:code].blank?
+
+ ctx = Youtube::OauthState.verify!(params[:state])
tokens = Youtube::OauthExchange.call(params[:code])
+
+ if ctx[:kind] == :platform
+ unless platform_oauth_authorized?(ctx[:admin_id])
+ return redirect_to "#{admin_base_url}/login", alert: "Sessione admin richiesta", allow_other_host: true
+ end
+
+ return render_platform_token(tokens)
+ end
+
+ user = User.find(ctx[:user_id])
+ team = user.manageable_teams.find(ctx[:team_id])
cred = team.youtube_credential || team.build_youtube_credential
cred.update!(
access_token: tokens[:access_token],
- refresh_token: tokens[:refresh_token],
+ refresh_token: tokens[:refresh_token] || cred.refresh_token,
expires_at: Time.current + tokens[:expires_in].seconds,
channel_id: tokens[:channel_id],
channel_title: tokens[:channel_title]
)
- redirect_to "/admin/teams/#{team.id}?youtube=connected"
+ redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true
+ rescue ArgumentError, Youtube::BroadcastService::Error => e
+ redirect_to_oauth_error(e.message)
+ end
+
+ private
+
+ def public_base_url
+ MatchLiveTv.app_public_url.chomp("/")
+ end
+
+ def redirect_to_oauth_error(message)
+ redirect_to "#{public_base_url}/prezzi?youtube_error=#{ERB::Util.url_encode(message)}",
+ allow_other_host: true
+ end
+
+ def render_platform_token(tokens)
+ @refresh_token = tokens[:refresh_token]
+ @channel_title = tokens[:channel_title]
+ render "admin/youtube/platform_token", layout: "admin"
+ end
+
+ def platform_oauth_authorized?(admin_id)
+ session[:admin_account_id].present? && session[:admin_account_id].to_s == admin_id.to_s
+ end
+
+ def admin_base_url
+ "#{public_base_url}/admin"
end
end
end
diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb
index 4969703..3fcaae1 100644
--- a/backend/app/controllers/public/teams_controller.rb
+++ b/backend/app/controllers/public/teams_controller.rb
@@ -7,6 +7,7 @@ module Public
before_action :set_team, only: %i[
details dashboard roster edit update invite create_invitation
assign_self_staff clear_self_staff remove_member destroy_invitation
+ youtube_connect youtube_disconnect
]
def new
@@ -114,6 +115,27 @@ module Public
redirect_to public_team_details_path(@team), notice: "Invito annullato"
end
+ def youtube_connect
+ require_club_owner_for_team!(@team)
+ @team.entitlements.assert_can_connect_youtube!
+
+ if ENV["YOUTUBE_CLIENT_ID"].blank?
+ redirect_to public_team_details_path(@team), alert: "YouTube OAuth non configurato sul server"
+ return
+ end
+
+ state = Youtube::OauthState.for_team(team_id: @team.id, user_id: current_user.id)
+ redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
+ rescue Teams::EntitlementError => e
+ redirect_to public_team_details_path(@team), alert: e.message
+ end
+
+ def youtube_disconnect
+ require_club_owner_for_team!(@team)
+ @team.youtube_credential&.destroy!
+ redirect_to public_team_details_path(@team), notice: "Canale YouTube scollegato"
+ end
+
private
def load_team_details!
diff --git a/backend/app/models/plan.rb b/backend/app/models/plan.rb
index 74b2045..ebd4f00 100644
--- a/backend/app/models/plan.rb
+++ b/backend/app/models/plan.rb
@@ -25,9 +25,11 @@ class Plan < ApplicationRecord
end
def allows_platform?(platform)
- return false if platform.to_s == "youtube" && !youtube_enabled?
+ platform = platform.to_s
+ return false if platform == "youtube" && !youtube_enabled?
+ return true if platform == "youtube" && youtube_mode == "matchlivetv_light"
- Array(features["platforms"]).map(&:to_s).include?(platform.to_s)
+ Array(features["platforms"]).map(&:to_s).include?(platform)
end
def feature(key)
diff --git a/backend/app/models/youtube/platform_credential.rb b/backend/app/models/youtube/platform_credential.rb
new file mode 100644
index 0000000..af0b123
--- /dev/null
+++ b/backend/app/models/youtube/platform_credential.rb
@@ -0,0 +1,23 @@
+module Youtube
+ # Credenziali del canale YouTube Match Live TV (Premium Light), da ENV.
+ class PlatformCredential
+ attr_accessor :access_token, :expires_at
+
+ def refresh_token
+ ENV["YOUTUBE_PLATFORM_REFRESH_TOKEN"].presence
+ end
+
+ def expired?
+ expires_at.present? && expires_at < Time.current
+ end
+
+ def update!(attrs)
+ self.access_token = attrs[:access_token] if attrs.key?(:access_token)
+ self.expires_at = attrs[:expires_at] if attrs.key?(:expires_at)
+ end
+
+ def self.configured?
+ ENV["YOUTUBE_PLATFORM_REFRESH_TOKEN"].present?
+ end
+ end
+end
diff --git a/backend/app/services/youtube/broadcast_service.rb b/backend/app/services/youtube/broadcast_service.rb
index c82a88d..de8d9d5 100644
--- a/backend/app/services/youtube/broadcast_service.rb
+++ b/backend/app/services/youtube/broadcast_service.rb
@@ -4,7 +4,8 @@ module Youtube
def initialize(team)
@team = team
- @credential = team.youtube_credential
+ @resolver = CredentialResolver.new(team)
+ @credential = @resolver.resolve
end
def create_broadcast!(title:, privacy_status: "unlisted", scheduled_at: nil)
diff --git a/backend/app/services/youtube/channel_info.rb b/backend/app/services/youtube/channel_info.rb
new file mode 100644
index 0000000..89eab6a
--- /dev/null
+++ b/backend/app/services/youtube/channel_info.rb
@@ -0,0 +1,18 @@
+module Youtube
+ class ChannelInfo
+ def self.fetch(access_token:)
+ service = Google::Apis::YoutubeV3::YouTubeService.new
+ service.authorization = access_token
+ resp = service.list_channels("snippet", mine: true, max_results: 1)
+ item = resp.items&.first
+ return { channel_id: nil, channel_title: nil } unless item
+
+ {
+ channel_id: item.id,
+ channel_title: item.snippet&.title
+ }
+ rescue Google::Apis::Error
+ { channel_id: nil, channel_title: nil }
+ end
+ end
+end
diff --git a/backend/app/services/youtube/credential_resolver.rb b/backend/app/services/youtube/credential_resolver.rb
new file mode 100644
index 0000000..ed6d4e8
--- /dev/null
+++ b/backend/app/services/youtube/credential_resolver.rb
@@ -0,0 +1,24 @@
+module Youtube
+ class CredentialResolver
+ def self.for_team(team)
+ new(team).resolve
+ end
+
+ def initialize(team)
+ @team = team
+ @mode = team.entitlements.plan.youtube_mode
+ end
+
+ def resolve
+ if @mode == "matchlivetv_light" && PlatformCredential.configured?
+ PlatformCredential.new
+ else
+ @team.youtube_credential
+ end
+ end
+
+ def uses_platform_channel?
+ @mode == "matchlivetv_light" && PlatformCredential.configured?
+ end
+ end
+end
diff --git a/backend/app/services/youtube/oauth_exchange.rb b/backend/app/services/youtube/oauth_exchange.rb
index 7a47726..9aab1eb 100644
--- a/backend/app/services/youtube/oauth_exchange.rb
+++ b/backend/app/services/youtube/oauth_exchange.rb
@@ -13,12 +13,13 @@ module Youtube
data = JSON.parse(response.body)
raise BroadcastService::Error, data["error_description"] if data["error"]
+ info = ChannelInfo.fetch(access_token: data["access_token"])
{
access_token: data["access_token"],
refresh_token: data["refresh_token"],
expires_in: data["expires_in"].to_i,
- channel_id: nil,
- channel_title: nil
+ channel_id: info[:channel_id],
+ channel_title: info[:channel_title]
}
end
end
diff --git a/backend/app/services/youtube/oauth_state.rb b/backend/app/services/youtube/oauth_state.rb
new file mode 100644
index 0000000..d0af87f
--- /dev/null
+++ b/backend/app/services/youtube/oauth_state.rb
@@ -0,0 +1,39 @@
+module Youtube
+ class OauthState
+ PURPOSE = :youtube_oauth_connect
+
+ class << self
+ def for_team(team_id:, user_id:)
+ verifier.generate([team_id.to_s, user_id.to_s], expires_in: 1.hour)
+ end
+
+ def for_platform(admin_id:)
+ verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour)
+ end
+
+ def verify!(token)
+ payload = verifier.verify(token)
+ case payload
+ when Array
+ if payload.first == "platform"
+ { kind: :platform, admin_id: payload[1] }
+ elsif payload.size == 2
+ { kind: :team, team_id: payload[0], user_id: payload[1] }
+ else
+ raise ArgumentError, "stato OAuth non valido"
+ end
+ else
+ raise ArgumentError, "stato OAuth non valido"
+ end
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
+ raise ArgumentError, "stato OAuth scaduto o non valido"
+ end
+
+ private
+
+ def verifier
+ Rails.application.message_verifier(PURPOSE)
+ end
+ end
+ end
+end
diff --git a/backend/app/services/youtube/oauth_url.rb b/backend/app/services/youtube/oauth_url.rb
index 36b3738..7672d09 100644
--- a/backend/app/services/youtube/oauth_url.rb
+++ b/backend/app/services/youtube/oauth_url.rb
@@ -6,7 +6,7 @@ module Youtube
"https://www.googleapis.com/auth/youtube.force-ssl"
].join(" ").freeze
- def self.build(team_id:, redirect_uri:)
+ def self.build(state:, redirect_uri: ENV["YOUTUBE_REDIRECT_URI"])
params = {
client_id: ENV.fetch("YOUTUBE_CLIENT_ID", "not_configured"),
redirect_uri: redirect_uri,
@@ -14,7 +14,7 @@ module Youtube
scope: SCOPES,
access_type: "offline",
prompt: "consent",
- state: team_id
+ state: state
}
"#{AUTH_URL}?#{URI.encode_www_form(params)}"
end
diff --git a/backend/app/services/youtube/team_status.rb b/backend/app/services/youtube/team_status.rb
new file mode 100644
index 0000000..e83c03b
--- /dev/null
+++ b/backend/app/services/youtube/team_status.rb
@@ -0,0 +1,44 @@
+module Youtube
+ class TeamStatus
+ PLATFORM_LABEL = "Match Live TV".freeze
+
+ def initialize(team)
+ @team = team
+ @ent = team.entitlements
+ @mode = @ent.plan.youtube_mode
+ end
+
+ def selectable?
+ return false unless @ent.youtube_enabled?
+
+ case @mode
+ when "matchlivetv_light"
+ PlatformCredential.configured?
+ when "team"
+ @team.youtube_credential.present?
+ else
+ false
+ end
+ end
+
+ def connected?
+ selectable?
+ end
+
+ def uses_platform_channel?
+ @mode == "matchlivetv_light" && PlatformCredential.configured?
+ end
+
+ def channel_title
+ if uses_platform_channel?
+ PLATFORM_LABEL
+ else
+ @team.youtube_credential&.channel_title
+ end
+ end
+
+ def needs_team_oauth?
+ @mode == "team" && @ent.premium_full?
+ end
+ end
+end
diff --git a/backend/app/views/admin/teams/show.html.erb b/backend/app/views/admin/teams/show.html.erb
index 99e2b9b..e1a76fa 100644
--- a/backend/app/views/admin/teams/show.html.erb
+++ b/backend/app/views/admin/teams/show.html.erb
@@ -3,7 +3,12 @@
<% if @team.club %>
Società: <%= @team.club.name %> · <%= link_to "Pagamenti e fatture", admin_billing_path(club_id: @team.club.id) %>
<% end %>
-YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %>
+<% yt = Youtube::TeamStatus.new(@team) %>
+YouTube: <%= yt.channel_title || "—" %> · <%= yt.selectable? ? "pronto" : "non pronto" %>
+<% if @team.entitlements.plan.youtube_mode == "team" && @team.youtube_credential.blank? %>
+ <%= link_to "Collega (da account titolare sul sito)", public_team_details_path(@team) %>
+<% end %>
+<%= link_to "Token canale Match Live TV (Light)", admin_youtube_platform_path %>
Partite
diff --git a/backend/app/views/admin/youtube/platform_token.html.erb b/backend/app/views/admin/youtube/platform_token.html.erb
new file mode 100644
index 0000000..a52bc10
--- /dev/null
+++ b/backend/app/views/admin/youtube/platform_token.html.erb
@@ -0,0 +1,15 @@
+Token canale Match Live TV
+
+<% if @channel_title.present? %>
+ Canale: <%= @channel_title %>
+<% end %>
+
+<% if @refresh_token.present? %>
+ Aggiungi in /opt/matchlivetv/infra/.env (o locale infra/.env):
+
YOUTUBE_PLATFORM_REFRESH_TOKEN=<%= @refresh_token %>
+ Copia ora: non verrà mostrato di nuovo. Poi riavvia il container Rails.
+<% else %>
+ Google non ha restituito un refresh token. Ripeti il collegamento con prompt=consent (revoca l’accesso precedente in Google Account).
+<% end %>
+
+<%= link_to "← Dashboard admin", admin_root_path %>
diff --git a/backend/app/views/public/teams/_youtube.html.erb b/backend/app/views/public/teams/_youtube.html.erb
new file mode 100644
index 0000000..6a58f81
--- /dev/null
+++ b/backend/app/views/public/teams/_youtube.html.erb
@@ -0,0 +1,46 @@
+<% yt = Youtube::TeamStatus.new(team) %>
+<% return unless entitlements.youtube_enabled? %>
+
+
+ YouTube Live
+
+ <% if params[:youtube] == "connected" %>
+ Canale YouTube collegato con successo.
+ <% end %>
+
+ <% if entitlements.plan.youtube_mode == "matchlivetv_light" %>
+
+ Con il piano Premium Light la diretta va sul canale ufficiale
+ Match Live TV. Non serve collegare un canale della società.
+
+ <% if yt.selectable? %>
+ Canale pronto · seleziona «YouTube Live» nell’app mobile.
+ <% else %>
+ Canale Match Live TV in configurazione lato server. Contatta il supporto se YouTube non compare in app.
+ <% end %>
+ <% elsif entitlements.premium_full? %>
+ <% cred = team.youtube_credential %>
+ <% if cred %>
+
+ Canale collegato:
+ <%= cred.channel_title.presence || cred.channel_id || "YouTube" %>
+
+ <%= 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." } } %>
+ <% else %>
+
+ Collega il canale YouTube della società per trasmettere dall’app con la piattaforma «YouTube Live».
+
+ <% if ENV["YOUTUBE_CLIENT_ID"].present? %>
+ <%= link_to "Collega canale YouTube", public_team_youtube_connect_path(team), class: "btn btn-primary" %>
+ <% else %>
+ OAuth YouTube non ancora configurato sul server.
+ <% end %>
+ <% end %>
+ <% else %>
+ YouTube sul canale della società richiede il piano Premium Full.
+ <%= link_to "Vedi piani", public_prezzi_path, class: "btn btn-secondary" %>
+ <% end %>
+
diff --git a/backend/app/views/public/teams/details.html.erb b/backend/app/views/public/teams/details.html.erb
index 92a3a91..9d6a05d 100644
--- a/backend/app/views/public/teams/details.html.erb
+++ b/backend/app/views/public/teams/details.html.erb
@@ -92,6 +92,8 @@
<% end %>
+ <%= render "public/teams/youtube", team: @team, entitlements: @entitlements %>
+
<%= render "public/teams/streaming_staff", team: @team, club: @club, can_manage: @can_manage,
entitlements: @entitlements, owner_membership: @owner_membership,
staff_memberships: @staff_memberships, pending_invitations: @pending_invitations,
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 5390985..377267c 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -71,6 +71,7 @@ Rails.application.routes.draw do
resources :sessions, only: %i[index show] do
member { post :stop }
end
+ get "youtube/platform", to: "youtube#platform", as: :youtube_platform
end
get "hls/*path", to: "hls_proxy#show", format: false, constraints: { path: /.+/ }
@@ -138,6 +139,8 @@ Rails.application.routes.draw do
delete "teams/:id/staff/self", to: "teams#clear_self_staff", as: :team_clear_self_staff
delete "teams/:id/members/:user_id", to: "teams#remove_member", as: :team_remove_member
delete "teams/:id/invitations/:invitation_id", to: "teams#destroy_invitation", as: :team_destroy_invitation
+ get "teams/:id/youtube/connect", to: "teams#youtube_connect", as: :team_youtube_connect
+ delete "teams/:id/youtube/disconnect", to: "teams#youtube_disconnect", as: :team_youtube_disconnect
get "join/:token", to: "invitations#show", as: :invitation
post "join/:token", to: "invitations#accept"
end
diff --git a/backend/db/migrate/20260525120000_enable_youtube_on_premium_light_plan.rb b/backend/db/migrate/20260525120000_enable_youtube_on_premium_light_plan.rb
new file mode 100644
index 0000000..59734d3
--- /dev/null
+++ b/backend/db/migrate/20260525120000_enable_youtube_on_premium_light_plan.rb
@@ -0,0 +1,20 @@
+class EnableYoutubeOnPremiumLightPlan < ActiveRecord::Migration[7.2]
+ def up
+ plan = Plan.find_by(slug: "premium_light")
+ return unless plan
+
+ features = plan.features.deep_dup
+ platforms = Array(features["platforms"]).map(&:to_s)
+ features["platforms"] = (platforms + ["youtube"]).uniq
+ plan.update!(features: features)
+ end
+
+ def down
+ plan = Plan.find_by(slug: "premium_light")
+ return unless plan
+
+ features = plan.features.deep_dup
+ features["platforms"] = Array(features["platforms"]).map(&:to_s) - ["youtube"]
+ plan.update!(features: features)
+ end
+end
diff --git a/backend/db/seeds/plans.rb b/backend/db/seeds/plans.rb
index 8429dec..9d2ab3b 100644
--- a/backend/db/seeds/plans.rb
+++ b/backend/db/seeds/plans.rb
@@ -27,7 +27,7 @@ seed_plan!("premium_light", "Premium Light", {
"max_staff_transmission" => 5,
"max_staff_regia" => 5,
"concurrent_streams_limit" => 3,
- "platforms" => %w[matchlivetv],
+ "platforms" => %w[matchlivetv youtube],
"recordings_enabled" => true,
"recording_days" => 30,
"phone_download_enabled" => true,
diff --git a/docs/YOUTUBE_SETUP.md b/docs/YOUTUBE_SETUP.md
new file mode 100644
index 0000000..1c0febf
--- /dev/null
+++ b/docs/YOUTUBE_SETUP.md
@@ -0,0 +1,89 @@
+# Integrazione YouTube Live
+
+Match Live TV supporta due modalità YouTube, legate al piano società:
+
+| Piano | Modalità | Dove va la diretta |
+|-------|----------|-------------------|
+| **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.
+
+---
+
+## 1. Google Cloud Console
+
+1. [Google Cloud Console](https://console.cloud.google.com/) → nuovo progetto o progetto esistente.
+2. **API e servizi → Libreria** → abilita **YouTube Data API v3**.
+3. **API e servizi → Credenziali** → **Crea credenziali → ID client OAuth**:
+ - Tipo: **Applicazione web**
+ - URI di reindirizzamento autorizzati:
+ - `https://www.matchlivetv.it/api/v1/youtube/callback`
+ - `http://localhost:3000/api/v1/youtube/callback` (solo sviluppo)
+4. Copia **Client ID** e **Client Secret** in `infra/.env`:
+
+```bash
+YOUTUBE_CLIENT_ID=....apps.googleusercontent.com
+YOUTUBE_CLIENT_SECRET=GOCSPX-...
+YOUTUBE_REDIRECT_URI=https://www.matchlivetv.it/api/v1/youtube/callback
+```
+
+5. **Schermata di consenso OAuth**: stato di pubblicazione o utenti di test con gli account Google che collegheranno i canali.
+
+---
+
+## 2. Canale Match Live TV (Premium Light)
+
+Serve un **refresh token** del canale YouTube ufficiale Match Live TV.
+
+1. Una tantum: apri (da browser loggato sul canale Match Live TV):
+
+ `https://www.matchlivetv.it/admin/youtube/platform`
+
+ (solo admin tecnico) oppure usa lo script OAuth di Google con gli stessi scope dell’app.
+
+2. Salva in produzione (`/opt/matchlivetv/infra/.env`):
+
+```bash
+YOUTUBE_PLATFORM_REFRESH_TOKEN=1//0e...
+```
+
+3. Riavvia Rails. Le società **Premium Light** potranno scegliere «YouTube» in app senza collegare un canale proprio.
+
+Se manca, l’app userà solo Match Live TV (HLS) e mostrerà YouTube disabilitato per Light.
+
+---
+
+## 3. Canale società (Premium Full)
+
+1. Titolare società → login su matchlivetv.it
+2. **Società → squadra → Dettagli** → sezione **YouTube**
+3. **Collega canale YouTube** → consenso Google → ritorno al sito
+4. Da app mobile: wizard trasmissione → piattaforma **YouTube Live**
+
+Revoca: pulsante **Scollega canale** nella stessa sezione.
+
+---
+
+## 4. Variabili opzionali (sviluppo)
+
+```bash
+# Se OAuth non configurato: broadcast finto + relay disattivato
+YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
+```
+
+---
+
+## 5. Verifica
+
+- Premium Full: dopo collegamento, `GET /api/v1/teams/:id` → `youtube_connected: true`, `youtube_selectable: true`
+- Premium Light: con `YOUTUBE_PLATFORM_REFRESH_TOKEN` → `youtube_selectable: true` anche senza OAuth squadra
+- Avvia diretta YouTube da app → in admin session compare broadcast ID → link YouTube Studio
+
+---
+
+## 6. Produzione
+
+- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
+- Il relay ffmpeg gira nel container MediaMTX (`runOnReady`).
+- Apri RTMP **1935** sul router verso il server.
diff --git a/infra/.env.example b/infra/.env.example
index 59152dd..ace08b0 100644
--- a/infra/.env.example
+++ b/infra/.env.example
@@ -5,6 +5,9 @@ JWT_SECRET=matchlivetv_jwt_dev_secret_change_in_prod
YOUTUBE_CLIENT_ID=
YOUTUBE_CLIENT_SECRET=
YOUTUBE_REDIRECT_URI=http://localhost:3000/api/v1/youtube/callback
+# Refresh token canale Match Live TV (Premium Light) — vedi docs/YOUTUBE_SETUP.md
+YOUTUBE_PLATFORM_REFRESH_TOKEN=
+YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
diff --git a/infra/.env.production.example b/infra/.env.production.example
index fd673b4..63cbbc2 100644
--- a/infra/.env.production.example
+++ b/infra/.env.production.example
@@ -17,9 +17,10 @@ CORS_ORIGINS=https://www.matchlivetv.it,https://matchlivetv.it
ALLOWED_HOSTS=www.matchlivetv.it,matchlivetv.it,192.168.1.146,localhost
YOUTUBE_REDIRECT_URI=https://www.matchlivetv.it/api/v1/youtube/callback
-# YouTube OAuth (opzionale)
+# YouTube OAuth — vedi docs/YOUTUBE_SETUP.md
YOUTUBE_CLIENT_ID=
YOUTUBE_CLIENT_SECRET=
+YOUTUBE_PLATFORM_REFRESH_TOKEN=
# Stripe LIVE (Dashboard in modalità Live — non riusare price_ di Test)
# Match Live TV → Premium Light | Match Live TV PRO → Premium Full
diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml
index b2801e5..1582800 100644
--- a/infra/docker-compose.prod.yml
+++ b/infra/docker-compose.prod.yml
@@ -70,6 +70,7 @@ services:
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
+ 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}
@@ -128,6 +129,8 @@ services:
JWT_SECRET: ${JWT_SECRET}
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
+ YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-}
+ 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}
depends_on:
diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml
index 6a90dc2..180b750 100644
--- a/infra/docker-compose.yml
+++ b/infra/docker-compose.yml
@@ -66,6 +66,7 @@ services:
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
YOUTUBE_REDIRECT_URI: ${YOUTUBE_REDIRECT_URI:-http://localhost:3000/api/v1/youtube/callback}
+ YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
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)}
diff --git a/mobile/lib/features/setup_wizard/step_transmission.dart b/mobile/lib/features/setup_wizard/step_transmission.dart
index 4f12e96..fc2c4cb 100644
--- a/mobile/lib/features/setup_wizard/step_transmission.dart
+++ b/mobile/lib/features/setup_wizard/step_transmission.dart
@@ -76,25 +76,52 @@ class _StepTransmissionState extends ConsumerState {
void _onYoutubeTap(Team? team) {
if (team == null) return;
- if (team.canUseYoutube && team.youtubeConnected) {
+ if (team.isYoutubeReady) {
setState(() => _platform = 'youtube');
return;
}
- if (team.canUseYoutube && !team.youtubeConnected) {
+ if (team.canUseYoutube && team.youtubeMode == 'team') {
showPremiumRequiredDialog(
context,
- message: 'Collega il canale YouTube dalla dashboard sul sito, poi seleziona YouTube qui.',
- billingUrl: team.billingUrl,
+ message: 'Collega il canale YouTube dalla pagina Dettagli squadra sul sito, poi seleziona YouTube qui.',
+ billingUrl: team.staffManageUrl ?? team.billingUrl,
+ );
+ return;
+ }
+ if (team.canUseYoutube && team.youtubeMode == 'matchlivetv_light') {
+ 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.',
+ ),
+ ),
);
return;
}
showPremiumRequiredDialog(
context,
- message: 'YouTube Live è incluso nel piano Premium Full.',
+ message: 'YouTube Live richiede Premium Light o Full.',
billingUrl: team.billingUrl,
);
}
+ String _youtubeSubtitle(Team? team) {
+ if (team == null || !team.canUseYoutube) {
+ return 'Premium Light o Full';
+ }
+ if (team.isYoutubeReady) {
+ final label = team.youtubeChannelTitle;
+ if (team.youtubeUsesPlatformChannel) {
+ return label != null ? 'Canale $label' : 'Canale Match Live TV';
+ }
+ return label != null ? 'Canale $label' : 'Canale collegato';
+ }
+ if (team.youtubeMode == 'matchlivetv_light') {
+ return 'Canale Match Live TV (in attivazione)';
+ }
+ return 'Collega canale sul sito';
+ }
+
void _onExternalPremiumTap(String platform, Team? team) {
showPremiumRequiredDialog(
context,
@@ -112,7 +139,7 @@ class _StepTransmissionState extends ConsumerState {
loading: () => const Center(child: CircularProgressIndicator(color: AppTheme.primaryRed)),
error: (_, __) => const Center(child: Text('Errore caricamento squadra')),
data: (team) {
- final youtubeSelectable = team != null && team.canUseYoutube && team.youtubeConnected;
+ final youtubeSelectable = team != null && team.isYoutubeReady;
return SingleChildScrollView(
padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12),
@@ -147,14 +174,10 @@ class _StepTransmissionState extends ConsumerState {
const SizedBox(height: 8),
_PlatformCard(
title: 'YouTube Live',
- subtitle: team?.canUseYoutube == true
- ? (team!.youtubeConnected
- ? 'Canale collegato'
- : 'Collega canale sul sito')
- : 'Premium Full — collegamento canale',
+ subtitle: _youtubeSubtitle(team),
selected: _platform == 'youtube',
enabled: youtubeSelectable,
- badge: team?.canUseYoutube == true ? null : 'Full',
+ badge: team?.canUseYoutube == true ? null : 'Premium',
onTap: () => _onYoutubeTap(team),
),
const SizedBox(height: 8),
diff --git a/mobile/lib/shared/models/team.dart b/mobile/lib/shared/models/team.dart
index c9e7d13..63089fe 100644
--- a/mobile/lib/shared/models/team.dart
+++ b/mobile/lib/shared/models/team.dart
@@ -5,6 +5,9 @@ class Team {
required this.sport,
this.logoUrl,
this.youtubeConnected = false,
+ this.youtubeSelectable = false,
+ this.youtubeChannelTitle,
+ this.youtubeUsesPlatformChannel = false,
this.planSlug = 'free',
this.planName = 'Free',
this.premiumActive = false,
@@ -36,6 +39,9 @@ class Team {
final bool canStream;
final String? logoUrl;
final bool youtubeConnected;
+ final bool youtubeSelectable;
+ final String? youtubeChannelTitle;
+ final bool youtubeUsesPlatformChannel;
final String planSlug;
final String planName;
final bool premiumActive;
@@ -55,7 +61,9 @@ class Team {
final bool youtubeEnabled;
final String? youtubeMode;
- bool get canUseYoutube => premiumFull && youtubeEnabled;
+ bool get canUseYoutube => youtubeEnabled && (premiumFull || youtubeMode == 'matchlivetv_light');
+
+ bool get isYoutubeReady => youtubeSelectable;
bool get canUseRecordings => recordingsEnabled;
bool get canDownloadOnPhone => phoneDownloadEnabled;
@@ -76,6 +84,9 @@ class Team {
canStream: json['can_stream'] as bool? ?? true,
logoUrl: json['logo_url'] as String?,
youtubeConnected: json['youtube_connected'] as bool? ?? false,
+ youtubeSelectable: json['youtube_selectable'] as bool? ?? false,
+ youtubeChannelTitle: json['youtube_channel_title'] as String?,
+ youtubeUsesPlatformChannel: json['youtube_uses_platform_channel'] as bool? ?? false,
planSlug: json['plan_slug'] as String? ?? 'free',
planName: json['plan_name'] as String? ?? 'Free',
premiumActive: json['premium_active'] as bool? ?? false,
diff --git a/scripts/deploy_youtube_production.sh b/scripts/deploy_youtube_production.sh
new file mode 100755
index 0000000..fb8e644
--- /dev/null
+++ b/scripts/deploy_youtube_production.sh
@@ -0,0 +1,71 @@
+#!/usr/bin/env bash
+# Deploy integrazione YouTube su produzione (sync + env + migrate + rebuild).
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+ENV_FILE="${ENV_FILE:-$ROOT/infra/.env}"
+SERVER="${DEPLOY_SERVER:-eminux@192.168.1.146}"
+REMOTE_DIR="${DEPLOY_PATH:-/opt/matchlivetv}"
+YOUTUBE_REDIRECT_URI="${YOUTUBE_REDIRECT_URI:-https://www.matchlivetv.it/api/v1/youtube/callback}"
+
+if [[ ! -f "$ENV_FILE" ]]; then
+ echo "File non trovato: $ENV_FILE" >&2
+ exit 1
+fi
+
+read_env() {
+ local key="$1"
+ grep -E "^${key}=" "$ENV_FILE" | head -1 | cut -d= -f2- | tr -d '\r'
+}
+
+YOUTUBE_CLIENT_ID="$(read_env YOUTUBE_CLIENT_ID)"
+YOUTUBE_CLIENT_SECRET="$(read_env YOUTUBE_CLIENT_SECRET)"
+YOUTUBE_PLATFORM_REFRESH_TOKEN="$(read_env YOUTUBE_PLATFORM_REFRESH_TOKEN)"
+
+if [[ -z "$YOUTUBE_CLIENT_ID" || -z "$YOUTUBE_CLIENT_SECRET" ]]; then
+ echo "Manca YOUTUBE_CLIENT_ID o YOUTUBE_CLIENT_SECRET in $ENV_FILE" >&2
+ exit 1
+fi
+
+echo "Sync codice → $SERVER:$REMOTE_DIR ..."
+bash "$ROOT/scripts/deploy/sync_to_server.sh"
+
+echo "Aggiorno variabili YouTube in $REMOTE_DIR/infra/.env ..."
+ssh "$SERVER" "bash -s" </dev/null; then
+ sed -i "s|^\${key}=.*|\${key}=\${val}|" "\$ENV"
+ else
+ echo "\${key}=\${val}" >> "\$ENV"
+ fi
+}
+upsert YOUTUBE_CLIENT_ID '${YOUTUBE_CLIENT_ID}'
+upsert YOUTUBE_CLIENT_SECRET '${YOUTUBE_CLIENT_SECRET}'
+upsert YOUTUBE_REDIRECT_URI '${YOUTUBE_REDIRECT_URI}'
+REMOTE
+
+if [[ -n "$YOUTUBE_PLATFORM_REFRESH_TOKEN" ]]; then
+ ssh "$SERVER" "grep -q '^YOUTUBE_PLATFORM_REFRESH_TOKEN=' $REMOTE_DIR/infra/.env && sed -i 's|^YOUTUBE_PLATFORM_REFRESH_TOKEN=.*|YOUTUBE_PLATFORM_REFRESH_TOKEN=${YOUTUBE_PLATFORM_REFRESH_TOKEN}|' $REMOTE_DIR/infra/.env || echo 'YOUTUBE_PLATFORM_REFRESH_TOKEN=${YOUTUBE_PLATFORM_REFRESH_TOKEN}' >> $REMOTE_DIR/infra/.env"
+fi
+
+echo "Rebuild stack + migrate..."
+ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml --env-file .env up -d --build rails sidekiq"
+sleep 25
+ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails db:migrate"
+ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails db:seed" || true
+
+echo "Verifica route admin YouTube..."
+ssh "$SERVER" "cd $REMOTE_DIR/infra && docker compose -f docker-compose.prod.yml exec -T rails bundle exec rails routes -g youtube_platform | head -5"
+
+echo ""
+echo "OK. Prossimi passi manuali:"
+echo " 1. Admin: https://www.matchlivetv.it/admin/login"
+echo " 2. Token canale Light: https://www.matchlivetv.it/admin/youtube/platform"
+if [[ -z "$YOUTUBE_PLATFORM_REFRESH_TOKEN" ]]; then
+ echo " (poi aggiungi YOUTUBE_PLATFORM_REFRESH_TOKEN al .env server e riavvia rails)"
+fi
+echo " 3. Full: Dettagli squadra → Collega canale YouTube"