diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb
index 50b4006..c6b5449 100644
--- a/backend/app/controllers/api/v1/stream_sessions_controller.rb
+++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb
@@ -146,10 +146,11 @@ module Api
platform: session.platform,
rtmp_ingest_url: session.rtmp_ingest_url,
hls_playback_url: session.hls_playback_url,
- watch_page_url: session.watch_page_url,
+ watch_page_url: session.matchlivetv_platform? ? session.watch_page_url : nil,
share_url: session.share_url,
youtube_watch_url: session.youtube_watch_url,
youtube_broadcast_id: session.youtube_broadcast_id,
+ youtube_ready: session.youtube_ready?,
privacy_status: session.privacy_status,
quality_preset: session.quality_preset,
target_bitrate: session.target_bitrate,
diff --git a/backend/app/controllers/public/live_controller.rb b/backend/app/controllers/public/live_controller.rb
index 9ab4819..b3ffdfc 100644
--- a/backend/app/controllers/public/live_controller.rb
+++ b/backend/app/controllers/public/live_controller.rb
@@ -14,6 +14,7 @@ module Public
sessions = StreamSession
.broadcasting
.publicly_listed
+ .where(platform: "matchlivetv")
.includes(:score_state, match: { team: :club })
sessions = sessions.joins(:match).where(matches: { team_id: team_ids }) if team_ids.present?
diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb
index 41ad3c9..17afa11 100644
--- a/backend/app/models/stream_session.rb
+++ b/backend/app/models/stream_session.rb
@@ -119,11 +119,13 @@ class StreamSession < ApplicationRecord
end
def share_url
- if platform == "youtube"
- youtube_watch_url.presence || watch_page_url
- else
- watch_page_url
- end
+ return youtube_watch_url if platform == "youtube"
+
+ watch_page_url
+ end
+
+ def youtube_ready?
+ platform == "youtube" && youtube_watch_url.present?
end
def link_only?
diff --git a/backend/app/services/streams/youtube_relay.rb b/backend/app/services/streams/youtube_relay.rb
index 0b30b3b..23d6bca 100644
--- a/backend/app/services/streams/youtube_relay.rb
+++ b/backend/app/services/streams/youtube_relay.rb
@@ -52,6 +52,19 @@ module Streams
pid.present? && process_alive?(pid)
end
+ # Riavvia il relay se è morto (es. avviato prima del segnale su _air).
+ def ensure_publishing!(session)
+ return unless session.platform == "youtube"
+ return if session.terminal?
+ return if session.stream_key.blank?
+
+ return if running?(session.id)
+
+ start(session)
+ rescue Error => e
+ Rails.logger.warn("[YoutubeRelay] ensure_publishing session=#{session.id}: #{e.message}")
+ end
+
private
def mediamtx_intake_url(session)
diff --git a/backend/app/services/webhooks/mediamtx_handler.rb b/backend/app/services/webhooks/mediamtx_handler.rb
index 3b35ffc..dd3fe43 100644
--- a/backend/app/services/webhooks/mediamtx_handler.rb
+++ b/backend/app/services/webhooks/mediamtx_handler.rb
@@ -26,6 +26,7 @@ module Webhooks
return if session.paused?
Streams::OverlayRelay.ensure_publishing!(session)
+ Streams::YoutubeRelay.ensure_publishing!(session) if session.platform == "youtube"
return if session.live? && session.stream_events.where(event_type: "connected").exists?
diff --git a/backend/app/services/youtube/setup_broadcast.rb b/backend/app/services/youtube/setup_broadcast.rb
index 481401b..f45b483 100644
--- a/backend/app/services/youtube/setup_broadcast.rb
+++ b/backend/app/services/youtube/setup_broadcast.rb
@@ -39,6 +39,15 @@ module Youtube
metadata: { broadcast_id: broadcast[:broadcast_id] },
occurred_at: Time.current
)
+ SessionChannel.broadcast_message(
+ @session,
+ {
+ type: "youtube_ready",
+ youtube_watch_url: @session.youtube_watch_url,
+ share_url: @session.share_url,
+ youtube_broadcast_id: @session.youtube_broadcast_id
+ }
+ )
@session
rescue BroadcastService::Error => e
@session.stream_events.create!(
diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb
index a9255c4..554b852 100644
--- a/backend/app/views/public/live/show.html.erb
+++ b/backend/app/views/public/live/show.html.erb
@@ -4,7 +4,7 @@
<% content_for :robots, "noindex, follow" %>
<% content_for :head do %>
- <% unless @stream_closed %>
+ <% unless @stream_closed || @session.platform == "youtube" %>
<% end %>
<% end %>
@@ -25,6 +25,19 @@
Il punteggio finale resta visibile sopra.
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
+<% elsif @session.platform == "youtube" %>
+
+
▶
+
Diretta su YouTube
+ <% if @session.youtube_watch_url.present? %>
+
Questa partita è trasmessa sul canale YouTube Match Live TV, non sul player del sito.
+ <%= link_to "Guarda su YouTube", @session.youtube_watch_url, class: "btn stream-ended-btn", target: "_blank", rel: "noopener" %>
+ <% else %>
+
Stiamo preparando la diretta sul canale YouTube. Aggiorna tra qualche secondo.
+
+ <% end %>
+
Condividi solo il link YouTube con tifosi e società.
+
<% else %>
diff --git a/mobile/lib/features/setup_wizard/step_network_test.dart b/mobile/lib/features/setup_wizard/step_network_test.dart
index e3e548d..315c8d6 100644
--- a/mobile/lib/features/setup_wizard/step_network_test.dart
+++ b/mobile/lib/features/setup_wizard/step_network_test.dart
@@ -45,6 +45,7 @@ class _StepNetworkTestState extends ConsumerState {
int _latencyMs = 0;
String _networkType = '—';
bool _starting = false;
+ Timer? _youtubePoll;
@override
void initState() {
@@ -54,6 +55,45 @@ class _StepNetworkTestState extends ConsumerState {
if (session?.score != null) {
ref.read(scoreProvider.notifier).reset(session!.score!);
}
+ _startYoutubeLinkPoll();
+ });
+ }
+
+ @override
+ void dispose() {
+ _youtubePoll?.cancel();
+ super.dispose();
+ }
+
+ void _startYoutubeLinkPoll() {
+ final session = ref.read(sessionProvider).session;
+ if (session == null || session.platform != 'youtube') return;
+ if (session.youtubeReady || (session.youtubeWatchUrl?.isNotEmpty ?? false)) {
+ return;
+ }
+
+ _youtubePoll?.cancel();
+ var attempts = 0;
+ _youtubePoll = Timer.periodic(const Duration(seconds: 2), (_) async {
+ attempts++;
+ if (!mounted || attempts > 30) {
+ _youtubePoll?.cancel();
+ return;
+ }
+ final current = ref.read(sessionProvider).session;
+ if (current == null) return;
+ try {
+ final updated =
+ await ref.read(apiClientProvider).fetchSession(current.id);
+ ref
+ .read(sessionProvider.notifier)
+ .setSession(updated, match: ref.read(sessionProvider).match);
+ if (updated.youtubeReady ||
+ (updated.youtubeWatchUrl?.isNotEmpty ?? false)) {
+ _youtubePoll?.cancel();
+ if (mounted) setState(() {});
+ }
+ } catch (_) {}
});
}
@@ -240,6 +280,24 @@ class _StepNetworkTestState extends ConsumerState {
),
const SizedBox(height: 12),
],
+ if (_testCompleted &&
+ session != null &&
+ session.platform == 'youtube' &&
+ shareUrl == null) ...[
+ Container(
+ padding: const EdgeInsets.all(14),
+ decoration: BoxDecoration(
+ color: AppTheme.surfaceElevated,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Text(
+ 'Preparazione diretta sul canale YouTube Match Live TV… '
+ 'Il link apparirà qui tra pochi secondi.',
+ style: theme.textTheme.bodySmall,
+ ),
+ ),
+ const SizedBox(height: 8),
+ ],
if (_testCompleted && session != null && shareUrl != null) ...[
Container(
padding: const EdgeInsets.all(14),
@@ -252,7 +310,7 @@ class _StepNetworkTestState extends ConsumerState {
children: [
Text(
session.platform == 'youtube'
- ? 'Link video YouTube'
+ ? 'Link diretta YouTube (canale Match Live TV)'
: 'Link diretta Match Live TV',
style: theme.textTheme.titleSmall,
),
@@ -261,6 +319,13 @@ class _StepNetworkTestState extends ConsumerState {
shareUrl,
style: theme.textTheme.bodySmall,
),
+ if (session.platform == 'youtube') ...[
+ const SizedBox(height: 6),
+ Text(
+ 'Condividi solo questo link: gli spettatori guardano su YouTube, non sul sito.',
+ style: theme.textTheme.bodySmall,
+ ),
+ ],
const SizedBox(height: 8),
Row(
children: [
diff --git a/mobile/lib/providers/session_provider.dart b/mobile/lib/providers/session_provider.dart
index 46809bb..753e717 100644
--- a/mobile/lib/providers/session_provider.dart
+++ b/mobile/lib/providers/session_provider.dart
@@ -47,6 +47,38 @@ class SessionNotifier extends StateNotifier {
state = state.copyWith(session: session, match: match);
}
+ void updateYoutubeLinks({
+ required String? youtubeWatchUrl,
+ required String? shareUrl,
+ String? youtubeBroadcastId,
+ bool youtubeReady = true,
+ }) {
+ final s = state.session;
+ if (s == null || s.platform != 'youtube') return;
+ state = state.copyWith(
+ session: StreamSession(
+ id: s.id,
+ matchId: s.matchId,
+ status: s.status,
+ platform: s.platform,
+ rtmpIngestUrl: s.rtmpIngestUrl,
+ hlsPlaybackUrl: s.hlsPlaybackUrl,
+ watchPageUrl: s.watchPageUrl,
+ shareUrl: shareUrl,
+ youtubeWatchUrl: youtubeWatchUrl,
+ youtubeBroadcastId: youtubeBroadcastId ?? s.youtubeBroadcastId,
+ youtubeReady: youtubeReady,
+ privacyStatus: s.privacyStatus,
+ qualityPreset: s.qualityPreset,
+ targetBitrate: s.targetBitrate,
+ startedAt: s.startedAt,
+ disconnectionCount: s.disconnectionCount,
+ score: s.score,
+ devices: s.devices,
+ ),
+ );
+ }
+
void setMatch(MatchModel match) {
state = state.copyWith(match: match);
}
@@ -91,6 +123,7 @@ class SessionNotifier extends StateNotifier {
shareUrl: s.shareUrl,
youtubeWatchUrl: s.youtubeWatchUrl,
youtubeBroadcastId: s.youtubeBroadcastId,
+ youtubeReady: s.youtubeReady,
privacyStatus: s.privacyStatus,
qualityPreset: s.qualityPreset,
targetBitrate: s.targetBitrate,
@@ -133,7 +166,10 @@ class SessionNotifier extends StateNotifier {
rtmpIngestUrl: session.rtmpIngestUrl,
hlsPlaybackUrl: session.hlsPlaybackUrl,
watchPageUrl: session.watchPageUrl,
+ shareUrl: session.shareUrl,
+ youtubeWatchUrl: session.youtubeWatchUrl,
youtubeBroadcastId: session.youtubeBroadcastId,
+ youtubeReady: session.youtubeReady,
privacyStatus: session.privacyStatus,
qualityPreset: session.qualityPreset,
targetBitrate: session.targetBitrate,
diff --git a/mobile/lib/shared/models/stream_session.dart b/mobile/lib/shared/models/stream_session.dart
index e2c4dca..5f97b39 100644
--- a/mobile/lib/shared/models/stream_session.dart
+++ b/mobile/lib/shared/models/stream_session.dart
@@ -13,6 +13,7 @@ class StreamSession {
this.shareUrl,
this.youtubeWatchUrl,
this.youtubeBroadcastId,
+ this.youtubeReady = false,
this.privacyStatus = 'public',
this.qualityPreset = '720p_30_2.5mbps',
this.targetBitrate = 2500000,
@@ -32,6 +33,7 @@ class StreamSession {
final String? shareUrl;
final String? youtubeWatchUrl;
final String? youtubeBroadcastId;
+ final bool youtubeReady;
final String privacyStatus;
final String qualityPreset;
final int targetBitrate;
@@ -62,6 +64,7 @@ class StreamSession {
shareUrl: json['share_url'] as String?,
youtubeWatchUrl: json['youtube_watch_url'] as String?,
youtubeBroadcastId: json['youtube_broadcast_id'] as String?,
+ youtubeReady: json['youtube_ready'] as bool? ?? false,
privacyStatus: json['privacy_status'] as String? ?? 'public',
qualityPreset: json['quality_preset'] as String? ?? '720p_30_2.5mbps',
targetBitrate: json['target_bitrate'] as int? ?? 2500000,
diff --git a/mobile/lib/shared/watch_share.dart b/mobile/lib/shared/watch_share.dart
index d9fa2d9..570d2b9 100644
--- a/mobile/lib/shared/watch_share.dart
+++ b/mobile/lib/shared/watch_share.dart
@@ -5,7 +5,10 @@ import 'package:share_plus/share_plus.dart';
import 'models/stream_session.dart';
String? watchShareUrl(StreamSession session) {
- return session.shareUrl ?? session.youtubeWatchUrl ?? session.watchPageUrl;
+ if (session.platform == 'youtube') {
+ return session.youtubeWatchUrl ?? session.shareUrl;
+ }
+ return session.shareUrl ?? session.watchPageUrl;
}
String watchShareLabel(StreamSession session) {
diff --git a/mobile/lib/shared/websocket_service.dart b/mobile/lib/shared/websocket_service.dart
index 5324677..f2806c4 100644
--- a/mobile/lib/shared/websocket_service.dart
+++ b/mobile/lib/shared/websocket_service.dart
@@ -121,6 +121,13 @@ class WebsocketService {
_ref.read(sessionProvider.notifier).applyCommand('resume_stream');
}
break;
+ case 'youtube_ready':
+ _ref.read(sessionProvider.notifier).updateYoutubeLinks(
+ youtubeWatchUrl: data['youtube_watch_url'] as String?,
+ shareUrl: data['share_url'] as String?,
+ youtubeBroadcastId: data['youtube_broadcast_id'] as String?,
+ );
+ break;
default:
break;
}