diff --git a/backend/Dockerfile b/backend/Dockerfile index bb6d341..bc2f3a9 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,7 +1,7 @@ FROM ruby:3.3-bookworm RUN apt-get update -qq && \ - apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg && \ + apt-get install -y --no-install-recommends build-essential libpq-dev curl ffmpeg librsvg2-bin fonts-dejavu-core && \ rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/backend/app/channels/session_channel.rb b/backend/app/channels/session_channel.rb index 75296ca..d50def1 100644 --- a/backend/app/channels/session_channel.rb +++ b/backend/app/channels/session_channel.rb @@ -59,16 +59,7 @@ class SessionChannel < ApplicationCable::Channel end def update_score(session, data) - score = session.score_state || session.create_score_state! - score.update!( - home_sets: data["home_sets"] || score.home_sets, - away_sets: data["away_sets"] || score.away_sets, - home_points: data["home_points"] || score.home_points, - away_points: data["away_points"] || score.away_points, - current_set: data["current_set"] || score.current_set, - set_partials: data.key?("set_partials") ? data["set_partials"] : score.set_partials - ) - SessionChannel.broadcast_message(session, score.as_cable_payload) + Scoring::SyncState.new(session: session, payload: data).call end def update_timeout(session, data) diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index fefb722..50b4006 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -34,6 +34,11 @@ module Api render json: session_json(@session) end + def score + Scoring::SyncState.new(session: @session, payload: score_sync_params).call + render json: session_json(@session, detail: true) + end + def events events = @session.stream_events.recent.limit(100) render json: events.map { |e| event_json(e) } @@ -126,6 +131,13 @@ module Api params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel) end + def score_sync_params + params.permit( + :home_sets, :away_sets, :home_points, :away_points, :current_set, + set_partials: %i[set home away] + ) + end + def session_json(session, detail: false) data = { id: session.id, diff --git a/backend/app/controllers/concerns/mediamtx_playback.rb b/backend/app/controllers/concerns/mediamtx_playback.rb new file mode 100644 index 0000000..aeb10c3 --- /dev/null +++ b/backend/app/controllers/concerns/mediamtx_playback.rb @@ -0,0 +1,34 @@ +module MediamtxPlayback + extend ActiveSupport::Concern + + private + + def mediamtx_paths_index + @mediamtx_paths_index ||= Mediamtx::Client.new.list_paths.index_by { |i| i["name"] } + end + + def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session)) + mediamtx_paths_index[path_name] + end + + def mediamtx_playback_path_name(session) + session.effective_hls_path_name + end + + # Player HLS: path _air con overlay quando il relay è attivo. + def mediamtx_stream_playable?(session) + return false if session.terminal? + + mediamtx_path_has_output?(session) + end + + def mediamtx_path_has_output?(session) + info = mediamtx_path_info(session) + info && (info["ready"] || info["available"] || info["online"]) + end + + def mediamtx_publisher_online?(session) + info = mediamtx_paths_index[session.mediamtx_path_name] + info && info["online"] + end +end diff --git a/backend/app/controllers/public/live_controller.rb b/backend/app/controllers/public/live_controller.rb index 8395f1b..9ab4819 100644 --- a/backend/app/controllers/public/live_controller.rb +++ b/backend/app/controllers/public/live_controller.rb @@ -1,5 +1,7 @@ module Public class LiveController < SiteBaseController + include MediamtxPlayback + layout "marketing_live" ACTIVE_STATUSES = %w[live connecting reconnecting].freeze @@ -40,31 +42,41 @@ module Public def show @session = StreamSession.includes(match: { team: :club }).find(params[:id]) + @session = Mediamtx::PublisherSync.new(@session).call unless @session.terminal? @match = @session.match @stream_closed = @session.terminal? - @on_air = !@stream_closed && mediamtx_online?(@session) + @on_air = !@stream_closed && mediamtx_stream_playable?(@session) + @publisher_online = !@stream_closed && mediamtx_publisher_online?(@session) end def status + # Mai cache: il tabellone deve restare allineato con l'app (poll ogni ~1,5s). + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate" + response.headers["Pragma"] = "no-cache" + session = StreamSession.includes(match: { team: :club }).find(params[:id]) + session = Mediamtx::PublisherSync.new(session).call unless session.terminal? closed = session.terminal? + publisher_online = !closed && mediamtx_publisher_online?(session) + playable = !closed && mediamtx_stream_playable?(session) render json: { status: session.status, + paused: session.paused?, stream_closed: closed, - live: !closed && (session.live? || mediamtx_online?(session)), - on_air: !closed && mediamtx_online?(session), + live: !closed && ( + session.live? || session.paused? || session.connecting? || session.reconnecting? || + mediamtx_publisher_online?(session) + ), + on_air: playable, + publisher_online: publisher_online, + awaiting_signal: !closed && !mediamtx_path_has_output?(session), + showing_cover: !closed && mediamtx_path_has_output?(session) && + (session.paused? || !mediamtx_publisher_online?(session)), ended_at: session.ended_at, home_name: session.match.team.name, away_name: session.match.opponent_name, score: session.score_state&.as_cable_payload } end - - private - - def mediamtx_online?(session) - @online_paths_cache ||= Mediamtx::Client.new.online_path_names - @online_paths_cache.include?(session.mediamtx_path_name) - end end end diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb index 7b3733e..2bd4e23 100644 --- a/backend/app/controllers/public/regia_controller.rb +++ b/backend/app/controllers/public/regia_controller.rb @@ -1,6 +1,7 @@ module Public class RegiaController < SiteBaseController include Public::LiveHelper + include MediamtxPlayback layout "regia" @@ -17,7 +18,7 @@ module Public ) @rules = Scoring::Rules.from_match(@match) @stream_closed = @session.terminal? - @on_air = !@stream_closed && mediamtx_online?(@session) + @on_air = !@stream_closed && mediamtx_stream_playable?(@session) @share_url = request.original_url @share_title = "Regia — #{@team.name} vs #{@match.opponent_name}" end @@ -61,12 +62,14 @@ module Public def status_payload score = @session.score_state closed = @session.terminal? + playable = !closed && mediamtx_stream_playable?(@session) { status: @session.status, paused: @session.paused?, stream_closed: closed, - live: !closed && (@session.live? || mediamtx_online?(@session)), - on_air: !closed && mediamtx_online?(@session), + live: !closed && (@session.live? || @session.paused? || mediamtx_publisher_online?(@session)), + on_air: playable, + publisher_online: !closed && mediamtx_publisher_online?(@session), ended_at: @session.ended_at, home_name: @session.match.team.name, away_name: @session.match.opponent_name, @@ -74,10 +77,5 @@ module Public score: score&.as_cable_payload } end - - def mediamtx_online?(session) - @online_paths_cache ||= Mediamtx::Client.new.online_path_names - @online_paths_cache.include?(session.mediamtx_path_name) - end end end diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb index e5e50e5..fac9d41 100644 --- a/backend/app/helpers/public/live_helper.rb +++ b/backend/app/helpers/public/live_helper.rb @@ -61,8 +61,27 @@ module Public "#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}" end + # Colore ospite sul tabellone: secondario società se leggibile su bianco, altrimenti blu visitatore. + def live_scorebug_away_color(team) + club = team.club + candidate = club&.effective_secondary_color.presence || "#1565c0" + return candidate unless light_hex_color?(candidate) + + "#1565c0" + end + private + def light_hex_color?(hex) + h = hex.to_s.delete_prefix("#") + return false unless h.match?(/\A\h{6}\z/i) + + r = h[0..1].to_i(16) + g = h[2..3].to_i(16) + b = h[4..5].to_i(16) + (0.299 * r + 0.587 * g + 0.114 * b) > 200 + end + def format_set_partial_entry(partial) data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial set_no = data[:set] || data["set"] diff --git a/backend/app/jobs/overlay_refresh_job.rb b/backend/app/jobs/overlay_refresh_job.rb new file mode 100644 index 0000000..18458f1 --- /dev/null +++ b/backend/app/jobs/overlay_refresh_job.rb @@ -0,0 +1,31 @@ +class OverlayRefreshJob < ApplicationJob + queue_as :default + + INTERVAL = 2.seconds + REDIS_CHAIN_KEY = "overlay_refresh:chain:%s" + + def self.schedule_chain(session_id) + redis.set(format(REDIS_CHAIN_KEY, session_id), "1", ex: 48.hours.to_i) + set(wait: INTERVAL).perform_later(session_id) + end + + def self.cancel_chain(session_id) + redis.del(format(REDIS_CHAIN_KEY, session_id)) + end + + def self.redis + @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) + end + + def perform(session_id) + return unless self.class.redis.get(format(REDIS_CHAIN_KEY, session_id)) + + session = StreamSession.find_by(id: session_id) + return self.class.cancel_chain(session_id) unless session + return self.class.cancel_chain(session_id) if session.terminal? + return self.class.cancel_chain(session_id) unless Streams::OverlayRelay.running?(session_id) + + Streams::Overlay::Refresh.call(session) + self.class.set(wait: INTERVAL).perform_later(session_id) + end +end diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb index 398219c..41ad3c9 100644 --- a/backend/app/models/stream_session.rb +++ b/backend/app/models/stream_session.rb @@ -54,11 +54,11 @@ class StreamSession < ApplicationRecord end event :pause do - transitions from: %i[live reconnecting], to: :paused + transitions from: %i[live reconnecting connecting], to: :paused end event :resume do - transitions from: :paused, to: :live + transitions from: :paused, to: :connecting end event :finish do @@ -81,13 +81,30 @@ class StreamSession < ApplicationRecord "live/match_#{id}" end + def mediamtx_overlay_path_name + "#{mediamtx_path_name}_air" + end + def matchlivetv_platform? platform == "matchlivetv" end def hls_playback_url base = MatchLiveTv.hls_public_url.chomp("/") - "#{base}/#{mediamtx_path_name}/index.m3u8" + "#{base}/#{effective_hls_path_name}/index.m3u8" + end + + def effective_hls_path_name + return mediamtx_path_name if terminal? + return mediamtx_overlay_path_name unless Streams::OverlayRelay.running?(id) + return mediamtx_overlay_path_name if overlay_air_has_output? + + mediamtx_path_name + end + + def overlay_air_has_output? + info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == mediamtx_overlay_path_name } + info && (info["online"] || info["ready"] || info["available"]) end def watch_page_url diff --git a/backend/app/services/mediamtx/client.rb b/backend/app/services/mediamtx/client.rb index 9fedb8c..25489b3 100644 --- a/backend/app/services/mediamtx/client.rb +++ b/backend/app/services/mediamtx/client.rb @@ -12,28 +12,32 @@ module Mediamtx end end - def create_path(session) - path = session.mediamtx_path_name - record = session.match.team.entitlements.recording_enabled_for_mediamtx? + def create_overlay_path(session) + path = session.mediamtx_overlay_path_name body = { source: "publisher", overridePublisher: true, - alwaysAvailable: true, - alwaysAvailableFile: slate_file_path, - alwaysAvailableTracks: slate_tracks, - record: record, - recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S", - recordSegmentDuration: "60s" + record: false } - if record - retention_hours = session.match.team.entitlements.recording_retention_days * 24 + 48 - body[:recordDeleteAfter] = "#{retention_hours}h" - end - unless session.matchlivetv_platform? - body[:runOnReady] = run_on_ready_script(session) - body[:runOnReadyRestart] = true - body[:runOnNotReady] = webhook_curl(session, "disconnect") + response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) + unless response.success? + err = response.body.is_a?(Hash) ? response.body["error"] : response.body + raise Error, "MediaMTX overlay path create failed: #{response.status} #{err}" end + true + end + + def create_path(session) + path = session.mediamtx_path_name + # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe + # solo la slate (schermo nero) in pausa/attesa. + body = recording_body(session, enabled: false).merge( + source: "publisher", + overridePublisher: true, + alwaysAvailable: true, + alwaysAvailableFile: slate_file_path + ) + # YouTube: relay continuo gestito da Streams::YoutubeRelay (ffmpeg in sidekiq/rails). response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) unless response.success? err = response.body.is_a?(Hash) ? response.body["error"] : response.body @@ -44,18 +48,29 @@ module Mediamtx def delete_path(session) delete_path_name(session.mediamtx_path_name) + delete_path_name(session.mediamtx_overlay_path_name) end def delete_path_name(path_name) @conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}") end + def set_path_recording(session, enabled:) + path = session.mediamtx_path_name + body = recording_body(session, enabled: enabled) + response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body) + unless response.success? + err = response.body.is_a?(Hash) ? response.body["error"] : response.body + raise Error, "MediaMTX recording patch failed: #{response.status} #{err}" + end + true + end + def patch_path(session) path = session.mediamtx_path_name body = { alwaysAvailable: true, - alwaysAvailableFile: slate_file_path, - alwaysAvailableTracks: slate_tracks + alwaysAvailableFile: slate_file_path } response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body) unless response.success? @@ -81,6 +96,21 @@ module Mediamtx private + def recording_body(session, enabled:) + ent = session.match.team.entitlements + can_record = ent.recording_enabled_for_mediamtx? + body = { + record: enabled && can_record, + recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S", + recordSegmentDuration: "60s" + } + if enabled && can_record + retention_hours = ent.recording_retention_days * 24 + 48 + body[:recordDeleteAfter] = "#{retention_hours}h" + end + body + end + def publish_webhook(session) webhook_curl(session, "connect") end @@ -122,12 +152,5 @@ module Mediamtx def slate_file_path ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4") end - - def slate_tracks - [ - { codec: "H264" }, - { codec: "MPEG4Audio", sampleRate: 48_000, channelCount: 2 } - ] - end end end diff --git a/backend/app/services/mediamtx/publisher_sync.rb b/backend/app/services/mediamtx/publisher_sync.rb new file mode 100644 index 0000000..f5b6a8a --- /dev/null +++ b/backend/app/services/mediamtx/publisher_sync.rb @@ -0,0 +1,51 @@ +module Mediamtx + # MediaMTX in produzione è distroless: runOnReady con wget non funziona. + # Sincronizziamo lo stato Rails leggendo l'API paths (poll da /live/:id/status.json). + class PublisherSync + def initialize(session) + @session = session + end + + def call + return @session if @session.terminal? + + info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name } + publisher_online = info && info["online"] + + if publisher_online + if @session.paused? + # RTMP ancora connesso in pausa: non forzare live/reconnect. + elsif @session.may_go_live? + @session.go_live! + Streams::Overlay::Refresh.call(@session.reload) + elsif @session.reconnecting? && @session.may_reconnect? + @session.reconnect! + cancel_timeout + Streams::Overlay::Refresh.call(@session.reload) + end + elsif !@session.paused? && @session.live? && @session.may_lose_connection? + @session.lose_connection! + schedule_timeout unless @session.paused? + end + + @session.reload + end + + private + + def cancel_timeout + return if @session.timeout_job_id.blank? + + Sidekiq::ScheduledSet.new.find_job(@session.timeout_job_id)&.delete + @session.update!(timeout_job_id: nil) + end + + def schedule_timeout + job = DisconnectionTimeoutJob.perform_in( + MatchLiveTv.reconnect_timeout_seconds.seconds, + @session.id + ) + @session.update!(timeout_job_id: job) + end + end +end diff --git a/backend/app/services/recordings/upload_from_session.rb b/backend/app/services/recordings/upload_from_session.rb index 7218fc8..45aa71e 100644 --- a/backend/app/services/recordings/upload_from_session.rb +++ b/backend/app/services/recordings/upload_from_session.rb @@ -64,8 +64,11 @@ module Recordings @merged_temp_path = File.join(Dir.tmpdir, "replay-#{@session.id}-#{SecureRandom.hex(4)}.mp4") list_path = "#{@merged_temp_path}.txt" File.write(list_path, source_files.map { |f| "file '#{f.gsub("'", "'\\''")}'" }.join("\n")) - success = system("ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", @merged_temp_path, - out: File::NULL, err: File::NULL) + success = system( + "ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, + "-c", "copy", "-movflags", "+faststart", @merged_temp_path, + out: File::NULL, err: File::NULL + ) FileUtils.rm_f(list_path) raise Error, "Impossibile unire i segmenti video" unless success && File.exist?(@merged_temp_path) diff --git a/backend/app/services/scoring/apply_action.rb b/backend/app/services/scoring/apply_action.rb index 74a8d21..4a0a9e3 100644 --- a/backend/app/services/scoring/apply_action.rb +++ b/backend/app/services/scoring/apply_action.rb @@ -105,6 +105,7 @@ module Scoring def broadcast(score) SessionChannel.broadcast_message(@session, score.as_cable_payload) + Streams::Overlay::Refresh.call(@session) end end end diff --git a/backend/app/services/scoring/sync_state.rb b/backend/app/services/scoring/sync_state.rb new file mode 100644 index 0000000..db30df9 --- /dev/null +++ b/backend/app/services/scoring/sync_state.rb @@ -0,0 +1,28 @@ +module Scoring + # Sincronizza lo stato completo del tabellone (app mobile / Action Cable). + class SyncState + SCORE_KEYS = %w[home_sets away_sets home_points away_points current_set].freeze + + def initialize(session:, payload:) + @session = session + @payload = payload.stringify_keys + end + + def call + score = @session.score_state || @session.create_score_state!( + home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: [] + ) + + attrs = {} + SCORE_KEYS.each do |key| + attrs[key] = @payload[key] if @payload.key?(key) + end + attrs[:set_partials] = @payload["set_partials"] if @payload.key?("set_partials") + + score.update!(attrs) + SessionChannel.broadcast_message(@session, score.as_cable_payload) + Streams::Overlay::Refresh.call(@session) + score + end + end +end diff --git a/backend/app/services/sessions/create.rb b/backend/app/services/sessions/create.rb index e69823f..68eab97 100644 --- a/backend/app/services/sessions/create.rb +++ b/backend/app/services/sessions/create.rb @@ -36,7 +36,11 @@ module Sessions StreamSession.transaction do session.save! session.create_score_state! - Mediamtx::Client.new.create_path(session) + mtx = Mediamtx::Client.new + mtx.create_path(session) + mtx.create_overlay_path(session) + start_overlay_relay(session) + start_youtube_relay(session) log_event(session, "pairing", { created: true, platform: session.platform }) end @@ -70,6 +74,20 @@ module Sessions session.rtmp_url = broadcast[:rtmp_url] end + def start_overlay_relay(session) + Streams::OverlayRelay.start(session) + rescue Streams::OverlayRelay::Error => e + Rails.logger.warn("[Sessions::Create] Overlay relay: #{e.message}") + end + + def start_youtube_relay(session) + return unless session.platform == "youtube" + + Streams::YoutubeRelay.start(session) + rescue Streams::YoutubeRelay::Error => e + Rails.logger.warn("[Sessions::Create] YouTube relay: #{e.message}") + end + def log_event(session, type, metadata) session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current) end diff --git a/backend/app/services/sessions/pause.rb b/backend/app/services/sessions/pause.rb index c0a6d09..e4e3a2b 100644 --- a/backend/app/services/sessions/pause.rb +++ b/backend/app/services/sessions/pause.rb @@ -7,10 +7,11 @@ module Sessions def call cancel_timeout_job @session.pause! if @session.may_pause? - ensure_slate_on_path + # Slate già su path (create_path + alwaysAvailable): stop RTMP basta, senza patch API. log_event("paused") SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" }) SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" }) + Streams::Overlay::Refresh.call(@session) @session end @@ -23,12 +24,6 @@ module Sessions @session.update!(timeout_job_id: nil) end - def ensure_slate_on_path - Mediamtx::Client.new.patch_path(@session) - rescue Mediamtx::Client::Error => e - Rails.logger.warn("[Sessions::Pause] slate path patch failed: #{e.message}") - end - def log_event(type) @session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {}) end diff --git a/backend/app/services/sessions/resume.rb b/backend/app/services/sessions/resume.rb index c9b0c27..c201c5a 100644 --- a/backend/app/services/sessions/resume.rb +++ b/backend/app/services/sessions/resume.rb @@ -10,9 +10,13 @@ module Sessions end cancel_timeout_job + # connecting finché RTMP non è online (evita lose_connection da PublisherSync) @session.begin_connect! if @session.may_begin_connect? + # Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher). log_event("resumed") SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" }) + SessionChannel.broadcast_message(@session, { type: "stream_event", event: "resumed" }) + Streams::Overlay::Refresh.call(@session) @session end diff --git a/backend/app/services/sessions/stop.rb b/backend/app/services/sessions/stop.rb index 2adfd75..662ef6b 100644 --- a/backend/app/services/sessions/stop.rb +++ b/backend/app/services/sessions/stop.rb @@ -6,6 +6,8 @@ module Sessions def call cancel_timeout_job + Streams::OverlayRelay.stop(@session) + Streams::YoutubeRelay.stop(@session) Sessions::RegiaAccess.revoke!(@session) @session.end_stream! complete_youtube_broadcast! if @session.youtube_broadcast_id.present? diff --git a/backend/app/services/streams/overlay.rb b/backend/app/services/streams/overlay.rb new file mode 100644 index 0000000..29b6018 --- /dev/null +++ b/backend/app/services/streams/overlay.rb @@ -0,0 +1,35 @@ +module Streams + module Overlay + module_function + + def overlay_dir(session_id) + Rails.root.join("tmp/stream_overlays", session_id.to_s) + end + + def overlay_png_path(session) + overlay_dir(session.id).join("overlay.png") + end + + def overlay_pipe_path(session) + overlay_dir(session.id).join("overlay.pipe") + end + + def badge_away_color(team) + club = team.club + candidate = club&.effective_secondary_color.presence || "#1565c0" + return candidate unless light_hex_color?(candidate) + + "#1565c0" + end + + def light_hex_color?(hex) + h = hex.to_s.delete_prefix("#") + return false unless h.match?(/\A\h{6}\z/i) + + r = h[0..1].to_i(16) + g = h[2..3].to_i(16) + b = h[4..5].to_i(16) + (0.299 * r + 0.587 * g + 0.114 * b) > 200 + end + end +end diff --git a/backend/app/services/streams/overlay/badge_label.rb b/backend/app/services/streams/overlay/badge_label.rb new file mode 100644 index 0000000..1fb15ec --- /dev/null +++ b/backend/app/services/streams/overlay/badge_label.rb @@ -0,0 +1,53 @@ +module Streams + module Overlay + # Etichetta stato broadcast (stessa logica della pagina live / badge HTML). + class BadgeLabel + Result = Struct.new(:text, :background, :foreground, keyword_init: true) + + def self.for(session) + new(session).call + end + + def initialize(session) + @session = session + end + + def call + return Result.new(text: "DIRETTA CHIUSA", background: "#374151", foreground: "#dddddd") if @session.terminal? + return Result.new(text: "IN PAUSA", background: "#455a64", foreground: "#ffffff") if @session.paused? + + if publisher_online? || (@session.live? && !@session.paused?) + return Result.new(text: "IN ONDA", background: "#2e7d32", foreground: "#ffffff") + end + + if path_has_output? + if @session.reconnecting? + return Result.new(text: "RICONNESSIONE", background: "#f57c00", foreground: "#ffffff") + end + if @session.connecting? + return Result.new(text: "CONNECTING", background: "#f57c00", foreground: "#ffffff") + end + return Result.new(text: "COPERTINA", background: "#455a64", foreground: "#ffffff") + end + + Result.new(text: "IN ATTESA", background: "#455a64", foreground: "#ffffff") + end + + private + + def path_info + @path_info ||= Mediamtx::Client.new.list_paths.find { |i| i["name"] == @session.mediamtx_path_name } + end + + def publisher_online? + info = path_info + info && info["online"] + end + + def path_has_output? + info = path_info + info && (info["ready"] || info["available"] || info["online"]) + end + end + end +end diff --git a/backend/app/services/streams/overlay/refresh.rb b/backend/app/services/streams/overlay/refresh.rb new file mode 100644 index 0000000..5cad538 --- /dev/null +++ b/backend/app/services/streams/overlay/refresh.rb @@ -0,0 +1,48 @@ +module Streams + module Overlay + class Refresh + class Error < StandardError; end + + def self.call(session) + new(session).call + end + + def initialize(session) + @session = session.reload + end + + def call + return false if @session.terminal? + + dir = Streams::Overlay.overlay_dir(@session.id) + FileUtils.mkdir_p(dir) + badge = BadgeLabel.for(@session) + svg = SvgBuilder.new(@session, badge: badge).to_svg + svg_path = dir.join("overlay.svg") + png_path = Streams::Overlay.overlay_png_path(@session) + atomic_write(svg_path, svg) + render_png!(svg_path, png_path) + true + rescue StandardError => e + Rails.logger.warn("[Overlay::Refresh] session=#{@session.id} #{e.class}: #{e.message}") + false + end + + private + + def atomic_write(path, content) + tmp = "#{path}.tmp" + File.write(tmp, content) + File.rename(tmp, path) + end + + def render_png!(svg_path, png_path) + unless system("rsvg-convert", "-w", "1280", "-h", "720", "-o", png_path.to_s, svg_path.to_s, out: File::NULL, err: File::NULL) + raise Error, "rsvg-convert fallito per session #{@session.id}" + end + + FileUtils.touch(png_path) + end + end + end +end diff --git a/backend/app/services/streams/overlay/svg_builder.rb b/backend/app/services/streams/overlay/svg_builder.rb new file mode 100644 index 0000000..86b4329 --- /dev/null +++ b/backend/app/services/streams/overlay/svg_builder.rb @@ -0,0 +1,145 @@ +module Streams + module Overlay + # PNG 1280×720 trasparente: tabellone alto-sinistra, badge alto-destra, brand sotto tabellone. + class SvgBuilder + CANVAS_W = 1280 + CANVAS_H = 720 + + def initialize(session, badge:) + @session = session + @match = session.match + @team = @match.team + @score = session.score_state + @badge = badge + end + + def to_svg + cols = score_columns + <<~SVG + + + #{scorebug_svg(cols)} + #{badge_svg} + + SVG + end + + private + + def score_columns + score = @score + unless score + return { + labels: ["1"], home: ["0"], away: ["0"], home_name: abbrev(@match.team.name), + away_name: abbrev(@match.opponent_name), sets_won: "0-0", current_set: 1 + } + end + + partials = Array(score.set_partials) + labels = partials.map { |p| p["set"].to_s } + home = partials.map { |p| p["home"].to_s } + away = partials.map { |p| p["away"].to_s } + labels << score.current_set.to_s + home << score.home_points.to_s + away << score.away_points.to_s + { + labels: labels, + home: home, + away: away, + home_name: abbrev(@match.team.name), + away_name: abbrev(@match.opponent_name), + sets_won: "#{score.home_sets}-#{score.away_sets}", + current_set: score.current_set + } + end + + def scorebug_svg(cols) + x0 = 12 + y0 = 12 + col_w = 26 + team_w = 118 + row_h = 18 + head_h = 16 + pad = 8 + n = cols[:labels].length + box_w = team_w + n * col_w + pad * 2 + box_h = head_h + row_h * 2 + 22 + pad * 2 + live_i = n - 1 + home_primary = @team.effective_primary_color + away_primary = Overlay.badge_away_color(@team) + + header_cells = cols[:labels].map.with_index do |label, i| + cx = x0 + pad + team_w + i * col_w + col_w / 2 + "#{escape(label)}" + end.join + + home_pts = cols[:home].map.with_index do |val, i| + cx = x0 + pad + team_w + i * col_w + col_w / 2 + y = y0 + pad + head_h + 14 + fill = i == live_i ? home_primary : "#111111" + weight = i == live_i ? "900" : "800" + bg = i == live_i ? "" : "" + "#{bg}#{escape(val)}" + end.join + + away_pts = cols[:away].map.with_index do |val, i| + cx = x0 + pad + team_w + i * col_w + col_w / 2 + y = y0 + pad + head_h + row_h + 14 + fill = i == live_i ? away_primary : "#111111" + weight = i == live_i ? "900" : "800" + bg = i == live_i ? "" : "" + "#{bg}#{escape(val)}" + end.join + + <<~SVG + + + + #{header_cells} + #{escape(cols[:home_name])} + #{home_pts} + #{escape(cols[:away_name])} + #{away_pts} + + MATCH LIVE TV + + + + + + + + + SVG + end + + def badge_svg + text = @badge.text + bg = @badge.background + fg = @badge.foreground + pad_x = 12 + pad_y = 6 + fs = 11 + tw = text.length * 6.5 + pad_x * 2 + th = fs + pad_y * 2 + x = CANVAS_W - 12 - tw + y = 12 + <<~SVG + + + #{escape(text)} + + SVG + end + + def abbrev(name, max = 16) + n = name.to_s + n.length <= max ? n : "#{n[0, max - 1]}…" + end + + def escape(str) + str.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">").gsub('"', """) + end + end + end +end diff --git a/backend/app/services/streams/overlay_relay.rb b/backend/app/services/streams/overlay_relay.rb new file mode 100644 index 0000000..c0d3bd2 --- /dev/null +++ b/backend/app/services/streams/overlay_relay.rb @@ -0,0 +1,205 @@ +module Streams + # Transcodifica path grezzo → path _air con overlay PNG (tabellone + badge) bruciato nel video. + class OverlayRelay + class Error < StandardError; end + + REDIS_KEY = "overlay_relay:pid:%s" + + class << self + def start(session) + return if session.terminal? + + stop(session) if pid_for(session.id).present? + + Streams::Overlay::Refresh.call(session) + + dir = Streams::Overlay.overlay_dir(session.id) + FileUtils.mkdir_p(dir) + png = Streams::Overlay.overlay_png_path(session) + pipe = Streams::Overlay.overlay_pipe_path(session) + ensure_pipe!(pipe) + intake = "#{mediamtx_rtmp_url}/#{session.mediamtx_path_name}" + output = "#{mediamtx_rtmp_url}/#{session.mediamtx_overlay_path_name}" + log_path = log_file(session) + FileUtils.mkdir_p(File.dirname(log_path)) + + fps = output_fps + overlay_fps = overlay_input_fps + filter = "[1:v]format=rgba[ol];[0:v]fps=#{fps},format=yuv420p[vid];[vid][ol]overlay=0:0:format=auto[vout]" + + ffmpeg_pid = Process.spawn( + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning", + "-fflags", "+genpts+discardcorrupt", + "-rw_timeout", "15000000", + "-i", intake, + "-f", "image2pipe", "-framerate", overlay_fps.to_s, "-i", pipe.to_s, + "-filter_complex", filter, + "-map", "[vout]", "-map", "0:a?", + "-fps_mode", "cfr", "-r", fps.to_s, + "-c:v", "libx264", "-preset", x264_preset, "-bf", "0", + "-profile:v", "high", "-pix_fmt", "yuv420p", + "-b:v", bitrate_for(session), "-maxrate", maxrate_for(session), "-bufsize", bufsize_for(session), + "-g", fps.to_s, "-keyint_min", fps.to_s, + "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1", + "-f", "flv", output, + %i[out err] => log_path, + pgroup: true + ) + spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path) + Process.detach(ffmpeg_pid) + store_pid(session.id, ffmpeg_pid) + OverlayRefreshJob.schedule_chain(session.id) + Rails.logger.info("[OverlayRelay] started pid=#{ffmpeg_pid} session=#{session.id}") + ffmpeg_pid + rescue Errno::ENOENT => e + raise Error, "ffmpeg non disponibile: #{e.message}" + end + + def stop(session) + pid = pid_for(session.id) + return false if pid.blank? + + terminate_pid(pid) + clear_pid(session.id) + pipe = Streams::Overlay.overlay_pipe_path(session) + FileUtils.rm_f(pipe) if pipe.exist? + OverlayRefreshJob.cancel_chain(session.id) + Rails.logger.info("[OverlayRelay] stopped pid=#{pid} session=#{session.id}") + true + end + + def running?(session_id) + pid = pid_for(session_id) + return false if pid.blank? + + return true unless overlay_relay_process_check_local? + + process_alive?(pid) + end + + # Riavvia il relay se il processo è morto o _air non pubblica (es. feeder bloccato). + def ensure_publishing!(session) + return if session.terminal? + + if running?(session.id) && overlay_path_publishing?(session) + return + end + + if running?(session.id) + Rails.logger.warn("[OverlayRelay] _air non attivo, restart session=#{session.id}") + stop(session) + end + + start(session) + rescue Error => e + Rails.logger.warn("[OverlayRelay] ensure_publishing session=#{session.id}: #{e.message}") + end + + private + + def mediamtx_rtmp_url + ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935").chomp("/") + end + + # Ricodifica con overlay: serve più budget della sorgente (generational loss). + def output_video_kbps(session) + override = ENV["OVERLAY_RELAY_VIDEO_KBPS"].presence&.to_i + return override if override&.positive? + + source_kbps = [(session.target_bitrate.to_i / 1000), 2500].max + [(source_kbps * 1.8).to_i, 4500].max + end + + def bitrate_for(session) + "#{output_video_kbps(session)}k" + end + + def maxrate_for(session) + kbps = (output_video_kbps(session) * 1.15).to_i + "#{kbps}k" + end + + def bufsize_for(session) + kbps = output_video_kbps(session) * 2 + "#{kbps}k" + end + + def output_fps + ENV.fetch("OVERLAY_RELAY_FPS", "30").to_i + end + + def x264_preset + ENV.fetch("OVERLAY_RELAY_X264_PRESET", "fast") + end + + def overlay_input_fps + ENV.fetch("OVERLAY_INPUT_FPS", "2").to_i + end + + def ensure_pipe!(path) + FileUtils.rm_f(path) if path.exist? && !path.pipe? + system("mkfifo", path.to_s) unless path.exist? + raise Error, "impossibile creare fifo overlay #{path}" unless path.pipe? + end + + def spawn_png_feeder!(ffmpeg_pid, pipe, png, log_path) + feeder = Rails.root.join("bin/overlay_png_feeder.rb") + poll = (1.0 / overlay_input_fps).round(2) + Process.spawn( + RbConfig.ruby, feeder.to_s, pipe.to_s, png.to_s, poll.to_s, + %i[out err] => log_path, + pgroup: ffmpeg_pid + ) + end + + def overlay_relay_process_check_local? + ENV["OVERLAY_RELAY_PROCESS_CHECK"] != "false" + end + + def overlay_path_publishing?(session) + info = Mediamtx::Client.new.list_paths.find { |i| i["name"] == session.mediamtx_overlay_path_name } + info && (info["online"] || info["ready"] || info["available"]) + end + + def log_file(session) + Rails.root.join("log", "overlay_relay_#{session.id}.log").to_s + end + + def redis + @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) + end + + def store_pid(session_id, pid) + redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i) + end + + def clear_pid(session_id) + redis.del(format(REDIS_KEY, session_id)) + end + + def pid_for(session_id) + redis.get(format(REDIS_KEY, session_id)) + end + + def process_alive?(pid) + Process.kill(0, pid.to_i) + true + rescue Errno::ESRCH, Errno::EPERM + false + end + + def terminate_pid(pid) + Process.kill("TERM", -pid.to_i) + sleep 0.5 + rescue Errno::ESRCH + nil + else + begin + Process.kill("KILL", -pid.to_i) + rescue Errno::ESRCH + nil + end + end + end + end +end diff --git a/backend/app/services/streams/youtube_relay.rb b/backend/app/services/streams/youtube_relay.rb new file mode 100644 index 0000000..0b30b3b --- /dev/null +++ b/backend/app/services/streams/youtube_relay.rb @@ -0,0 +1,103 @@ +module Streams + # Relay continuo verso YouTube: legge l'uscita MediaMTX del path (camera o slate alwaysAvailable) + # senza interrompere FLV quando il telefono si mette in pausa o perde rete. + class YoutubeRelay + class Error < StandardError; end + + REDIS_KEY = "youtube_relay:pid:%s" + + class << self + def start(session) + return unless session.platform == "youtube" + return if session.stream_key.blank? + return if session.terminal? + + stop(session) if pid_for(session.id).present? + + log_path = log_file(session) + FileUtils.mkdir_p(File.dirname(log_path)) + intake = mediamtx_intake_url(session) + output = "rtmp://a.rtmp.youtube.com/live2/#{session.stream_key}" + + pid = Process.spawn( + "ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning", + "-rw_timeout", "15000000", + "-i", intake, + "-c", "copy", + "-f", "flv", + output, + %i[out err] => log_path, + pgroup: true + ) + Process.detach(pid) + store_pid(session.id, pid) + Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id}") + pid + rescue Errno::ENOENT => e + raise Error, "ffmpeg non disponibile: #{e.message}" + end + + def stop(session) + pid = pid_for(session.id) + return false if pid.blank? + + terminate_pid(pid) + clear_pid(session.id) + Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id}") + true + end + + def running?(session_id) + pid = pid_for(session_id) + pid.present? && process_alive?(pid) + end + + private + + def mediamtx_intake_url(session) + base = ENV.fetch("MEDIAMTX_INTERNAL_RTMP_URL", "rtmp://mediamtx:1935") + "#{base.chomp('/')}/#{session.mediamtx_overlay_path_name}" + end + + def log_file(session) + Rails.root.join("log", "youtube_relay_#{session.id}.log").to_s + end + + def redis + @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) + end + + def store_pid(session_id, pid) + redis.set(format(REDIS_KEY, session_id), pid, ex: 48.hours.to_i) + end + + def clear_pid(session_id) + redis.del(format(REDIS_KEY, session_id)) + end + + def pid_for(session_id) + redis.get(format(REDIS_KEY, session_id)) + end + + def process_alive?(pid) + Process.kill(0, pid.to_i) + true + rescue Errno::ESRCH, Errno::EPERM + false + end + + def terminate_pid(pid) + Process.kill("TERM", -pid.to_i) + sleep 0.5 + rescue Errno::ESRCH + nil + else + begin + Process.kill("KILL", -pid.to_i) + rescue Errno::ESRCH + nil + end + end + end + end +end diff --git a/backend/app/services/webhooks/mediamtx_handler.rb b/backend/app/services/webhooks/mediamtx_handler.rb index 33a1e66..3b35ffc 100644 --- a/backend/app/services/webhooks/mediamtx_handler.rb +++ b/backend/app/services/webhooks/mediamtx_handler.rb @@ -25,21 +25,26 @@ module Webhooks def handle_connect(session) return if session.paused? + Streams::OverlayRelay.ensure_publishing!(session) + return if session.live? && session.stream_events.where(event_type: "connected").exists? if session.reconnecting? session.reconnect! if session.may_reconnect? + enable_recording(session) cancel_timeout(session) log(session, "reconnected") broadcast(session, "reconnected") elsif session.connecting? || session.idle? session.go_live! if session.may_go_live? + enable_recording(session) log(session, "connected") broadcast(session, "connected") end end def handle_disconnect(session) + disable_recording(session) return if session.paused? return unless session.live? || session.connecting? @@ -57,6 +62,20 @@ module Webhooks log(session, "connected", { ready: true }) end + def enable_recording(session) + return unless session.match.team.entitlements.recording_enabled_for_mediamtx? + + Mediamtx::Client.new.set_path_recording(session, enabled: true) + rescue Mediamtx::Client::Error => e + Rails.logger.warn("[MediamtxHandler] enable recording: #{e.message}") + end + + def disable_recording(session) + Mediamtx::Client.new.set_path_recording(session, enabled: false) + rescue Mediamtx::Client::Error => e + Rails.logger.warn("[MediamtxHandler] disable recording: #{e.message}") + end + def schedule_timeout(session) job = DisconnectionTimeoutJob.perform_in( MatchLiveTv.reconnect_timeout_seconds.seconds, @@ -92,6 +111,7 @@ module Webhooks def broadcast(session, event) SessionChannel.broadcast_message(session, { type: "stream_event", event: event }) + Streams::Overlay::Refresh.call(session) end end end diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index 884df17..c403298 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -7,7 +7,7 @@ <%= render "shared/meta_tags" %> - + <%= yield :head %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index a2cec46..a9255c4 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -13,23 +13,6 @@ <%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %> <%= live_match_page_heading(@match) %> -

- <% if @stream_closed %> - Diretta chiusa - <% elsif @on_air %> - In onda - <% else %> - In attesa - <% end %> -

- -
-

<%= live_score_sets_label(@session.score_state) || "—" %>

-

> - Parziali: <%= live_score_partials_label(@session.score_state) %> -

-

<%= live_score_points_label(@match, @session.score_state) %>

-
<% if @stream_closed %>
@@ -43,76 +26,94 @@ <%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
<% else %> - - +
+ + <%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %> + +
+ + + <% end %> diff --git a/backend/app/views/public/replay/show.html.erb b/backend/app/views/public/replay/show.html.erb index b60c0d6..e5a471e 100644 --- a/backend/app/views/public/replay/show.html.erb +++ b/backend/app/views/public/replay/show.html.erb @@ -17,7 +17,7 @@

Registrazione in elaborazione. Riceverai un’email quando sarà pronta.

- <% elsif @recording.ready? %> + <% elsif @recording.ready? && @recording.storage_key.present? %>
+ <% elsif @recording.ready? %> +
+

Il file video non è più disponibile (archivio rimosso o in migrazione).

+

Durata registrata: <%= @recording.duration_label %> · <%= @recording.byte_size_label %>

+
<% elsif @recording.status == "failed" %>

Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.

diff --git a/backend/bin/overlay_png_feeder.rb b/backend/bin/overlay_png_feeder.rb new file mode 100644 index 0000000..3de695e --- /dev/null +++ b/backend/bin/overlay_png_feeder.rb @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby +# Invia overlay.png aggiornata a ffmpeg via image2pipe (FIFO). +# ffmpeg con -loop 1 legge il file una sola volta: questo processo rilegge il PNG +# quando cambia mtime (punteggio, badge stato, …). +require "fileutils" + +pipe_path = ARGV.fetch(0) +png_path = ARGV.fetch(1) +poll_sec = (ARGV[2] || ENV.fetch("OVERLAY_PIPE_POLL_SEC", "0.4")).to_f + +abort "pipe mancante: #{pipe_path}" unless File.exist?(pipe_path) +abort "png mancante: #{png_path}" unless File.exist?(png_path) + +stop = false +trap("TERM") { stop = true } +trap("INT") { stop = true } + +# image2pipe richiede frame continui: inviamo la PNG a ritmo fisso ( anche se invariata ). +File.open(pipe_path, "wb") do |pipe| + loop do + break if stop + + if File.exist?(png_path) + pipe.write(File.binread(png_path)) + pipe.flush + end + + sleep poll_sec + end +rescue Errno::EPIPE + # ffmpeg chiuso +end diff --git a/backend/config/routes.rb b/backend/config/routes.rb index f1ebd1a..4bf7ca4 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -41,6 +41,7 @@ Rails.application.routes.draw do patch :stop patch :pause patch :resume + patch :score get :events post :telemetry post :pairing_token diff --git a/backend/lib/tasks/stream_overlay.rake b/backend/lib/tasks/stream_overlay.rake new file mode 100644 index 0000000..981fae1 --- /dev/null +++ b/backend/lib/tasks/stream_overlay.rake @@ -0,0 +1,18 @@ +namespace :stream_overlay do + desc "Avvia path _air e OverlayRelay per sessioni attive (dopo deploy overlay server)" + task start_active: :environment do + sessions = StreamSession.where(status: %w[live connecting reconnecting paused]) + mtx = Mediamtx::Client.new + sessions.find_each do |session| + begin + mtx.create_overlay_path(session) + rescue Mediamtx::Client::Error => e + Rails.logger.info("[stream_overlay:start_active] path #{session.id}: #{e.message}") + end + Streams::OverlayRelay.start(session) + puts "Overlay avviato per sessione #{session.id}" + rescue StandardError => e + warn "Sessione #{session.id}: #{e.message}" + end + end +end diff --git a/backend/public/images/copertina-canale.png b/backend/public/images/copertina-canale.png new file mode 100644 index 0000000..5896113 Binary files /dev/null and b/backend/public/images/copertina-canale.png differ diff --git a/backend/public/live.css b/backend/public/live.css index bd4d5e5..a9512b7 100644 --- a/backend/public/live.css +++ b/backend/public/live.css @@ -5,11 +5,285 @@ padding: 24px 20px 48px; } -.live-main video { +.live-main .live-player-wrap { + position: relative; width: 100%; - max-height: 70vh; - background: #000; + aspect-ratio: 16 / 9; + max-height: min(70vh, calc(100vw * 9 / 16)); + min-height: 200px; border-radius: 12px; + overflow: hidden; + background: #000; + contain: layout style; +} + +.live-main video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + max-height: none; + object-fit: contain; + background: #000; + border-radius: 0; + display: block; +} + +/* Sovraimpressioni chiare sul video (copertina scura). */ +.live-main .live-player-overlays { + position: absolute; + inset: 0; + z-index: 3; + pointer-events: none; +} + +.live-main .live-ovl-badge { + position: absolute; + top: 12px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.65); +} + +.live-main .live-ovl-badge--right { + right: 12px; + left: auto; +} + +/* Tabellone broadcast: tabella per colonne allineate (set / punteggi). */ +.live-main .live-scorebug { + position: absolute; + top: 12px; + left: 12px; + width: max-content; + max-width: min(92%, 320px); + padding: 6px 8px 5px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.94); + color: #111; + box-shadow: 0 2px 14px rgba(0, 0, 0, 0.45); + font-size: 0.8rem; + line-height: 1.2; +} + +.live-main .live-scorebug__table { + border-collapse: collapse; + table-layout: fixed; + width: auto; +} + +.live-main .live-scorebug__col-team { + width: 7.25rem; +} + +.live-main .live-scorebug__col-set { + width: 1.85rem; +} + +.live-main .live-scorebug__corner { + width: 7.25rem; + padding: 0; + border: none; + font-size: 0; + line-height: 0; + visibility: hidden; +} + +.live-main .live-scorebug thead th { + padding: 0 0 4px; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + font-size: 0.68rem; + font-weight: 700; + color: #666; + text-align: center; + vertical-align: bottom; +} + +.live-main .live-scorebug__col-h { + letter-spacing: 0.02em; +} + +.live-main .live-scorebug tbody th, +.live-main .live-scorebug tbody td { + padding: 3px 0; + vertical-align: middle; +} + +.live-main .live-scorebug tbody tr + tr th, +.live-main .live-scorebug tbody tr + tr td { + padding-top: 2px; +} + +.live-main .live-scorebug__team { + font-weight: 700; + text-align: left; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 7.25rem; + padding-right: 8px; +} + +.live-main .live-scorebug__row--home .live-scorebug__team { + color: var(--sb-home-primary, #111); +} + +.live-main .live-scorebug__row--away .live-scorebug__team { + color: var(--sb-away-primary, #1565c0); +} + +.live-main .live-scorebug__pts { + text-align: center; + font-weight: 800; + font-variant-numeric: tabular-nums; + color: #111; + padding-left: 0; + padding-right: 0; +} + +.live-main .live-scorebug__pts--live-home { + background: color-mix(in srgb, var(--sb-home-primary, #e53935) 16%, #fff); + border-radius: 3px; + color: var(--sb-home-primary, #111); + font-weight: 900; +} + +.live-main .live-scorebug__pts--live-away { + background: color-mix(in srgb, var(--sb-away-primary, #1565c0) 16%, #fff); + border-radius: 3px; + color: var(--sb-away-primary, #111); + font-weight: 900; +} + +.live-main .live-scorebug__brand { + margin-top: 5px; + padding: 4px 10px; + border-radius: 4px; + background: linear-gradient(135deg, #14141c 0%, #2a1214 55%, #1a1a24 100%); + border: 1px solid rgba(229, 57, 53, 0.35); + text-align: center; + line-height: 1.1; +} + +.live-main .live-scorebug__brand-text { + font-size: 0.7rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #fff; +} + +.live-main .live-scorebug__brand-accent { + color: #e53935; +} + +.live-main .live-scorebug .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (max-width: 520px) { + .live-main .live-scorebug { + font-size: 0.72rem; + max-width: 88%; + padding: 5px 6px 4px; + } + + .live-main .live-scorebug__col-team, + .live-main .live-scorebug__corner { + width: 5.75rem; + } + + .live-main .live-scorebug__team { + max-width: 5.75rem; + } + + .live-main .live-scorebug__col-set { + width: 1.55rem; + } +} + +.live-main .live-status-msg { + margin: 12px 0 0; + color: #f5f5f5; + font-size: 0.95rem; + text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); +} + +.live-main .live-status-msg[hidden] { + display: none !important; +} + +.live-main .live-play-hint { + position: absolute; + left: 50%; + top: 50%; + z-index: 5; + transform: translate(-50%, -50%); + padding: 12px 22px; + border: none; + border-radius: 999px; + background: rgba(229, 57, 53, 0.92); + color: #fff; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); + pointer-events: auto; +} + +.live-main .live-play-hint[hidden] { + display: none; +} + +.live-main .live-cover-veil { + z-index: 4; +} + +.live-main .live-cover-veil__hint { + color: #ffffff; + background: rgba(0, 0, 0, 0.72); + border: 1px solid rgba(255, 255, 255, 0.25); +} + +/* Messaggio pausa sopra il video: la copertina brand è nello stream HLS (MediaMTX). */ +.live-main .live-cover-veil { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0 16px 14%; + background: linear-gradient(180deg, transparent 55%, rgba(0, 0, 0, 0.55) 100%); + opacity: 0; + transition: opacity 0.4s ease; +} + +.live-main .live-cover-veil[hidden] { + display: none !important; +} + +.live-main .live-cover-veil.is-active { + opacity: 1; +} + +.live-main .live-cover-veil__hint { + margin: 0; + padding: 10px 18px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.65); + color: #fff; + font-size: clamp(0.9rem, 2.5vw, 1.1rem); + font-weight: 600; + text-align: center; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.8); } .live-main .badge { @@ -24,17 +298,24 @@ .live-main .badge-live { background: #e53935; color: #fff; } .live-main .badge-on-air { background: #2e7d32; color: #fff; } -.live-main .badge-wait { background: #444; color: #ddd; } +.live-main .badge-wait { background: rgba(0, 0, 0, 0.65); color: #fff; border: 1px solid rgba(255, 255, 255, 0.35); } .live-main .badge-connecting { background: #f57c00; color: #fff; } .live-main .badge-ended { background: #374151; color: #ddd; } .live-main .stream-ended { + position: absolute; + inset: 0; + z-index: 4; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; text-align: center; - padding: 48px 24px; - margin: 16px 0; + padding: 24px; + margin: 0; background: linear-gradient(160deg, #14141c 0%, #0a0a0e 100%); - border: 1px dashed #2a2a36; - border-radius: 16px; + border: none; + border-radius: 0; } .live-main .stream-ended-icon { diff --git a/backend/spec/services/scoring/sync_state_spec.rb b/backend/spec/services/scoring/sync_state_spec.rb new file mode 100644 index 0000000..8c6aa94 --- /dev/null +++ b/backend/spec/services/scoring/sync_state_spec.rb @@ -0,0 +1,33 @@ +require "rails_helper" + +RSpec.describe Scoring::SyncState do + let!(:user) { User.create!(email: "sync@test.it", name: "U", password: "password123", role: "coach") } + let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") } + + it "persiste punti zero dopo chiusura set" do + session.create_score_state!( + home_sets: 1, away_sets: 0, home_points: 25, away_points: 20, current_set: 2, + set_partials: [{ "set" => 1, "home" => 25, "away" => 20 }] + ) + + described_class.new( + session: session, + payload: { + "home_sets" => 1, + "away_sets" => 0, + "home_points" => 0, + "away_points" => 0, + "current_set" => 2, + "set_partials" => [{ "set" => 1, "home" => 25, "away" => 20 }] + } + ).call + + score = session.score_state.reload + expect(score.home_points).to eq(0) + expect(score.away_points).to eq(0) + expect(score.current_set).to eq(2) + end +end diff --git a/docs/LIVE_STREAMING.md b/docs/LIVE_STREAMING.md new file mode 100644 index 0000000..e2769c3 --- /dev/null +++ b/docs/LIVE_STREAMING.md @@ -0,0 +1,102 @@ +# Diretta live — architettura MediaMTX + +## Idea (non è folle) + +MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`): + +1. **Telefono in onda** → publisher RTMP (camera). +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. + +Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server. + +## 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) | +| `/hls/...` (Rails proxy) | Player web | + +## Pausa e continuità + +1. API `PATCH /sessions/:id/pause` → stato `paused`, registrazione off. +2. App → `pauseStream()` (stop RTMP). +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. + +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`). + +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) ──► proxy Rails /hls/... ──► player web (HLS.js) + └─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► 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`) | +| 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. + +### Player web — anti-scatti (HLS.js) + +Configurazione in `backend/app/views/public/live/show.html.erb`: + +- `maxLiveSyncPlaybackRate: 1.08` (evita accelerazioni visibili; prima 1.5 causava micro-scatti). +- `jumpToLiveEdge(force)` con throttle: seek solo se lag > 2s o `force`, e non più di una volta ogni ~8s se lag < 4s. +- **Niente** seek su `FRAG_BUFFERED` né nel poll ogni 1.5s. +- 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) + +Tabellone, badge stato («In onda», «In pausa», «Copertina», …) e banner **Match Live TV** sono **bruciati nel flusso** da `Streams::OverlayRelay`: + +``` +Telefono → live/match_{uuid} (grezzo) + └─► ffmpeg + PNG 1280×720 (Streams::Overlay::Refresh ogni ~2s) + └─► live/match_{uuid}_air + ├─► HLS (sito, anche fullscreen) + └─► YouTube relay (-c copy) +``` + +- `Streams::Overlay::SvgBuilder` + `rsvg-convert` generano `tmp/stream_overlays/{session_id}/overlay.png` +- `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`. + +### Punteggio (persistenza) + +L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay. + +`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`. + +## Rigenerare la slate + +```bash +cd infra +bash scripts/generate_slate.sh +# Copia su server: infra/slates/offline.mp4 montato in /slates nel container mediamtx +``` + +Formato obbligatorio: **H.264 1280×720 30fps, AAC 48 kHz mono** (allineato all’app). diff --git a/docs/REPLAY_MODULE.md b/docs/REPLAY_MODULE.md index abbfa22..a14f12c 100644 --- a/docs/REPLAY_MODULE.md +++ b/docs/REPLAY_MODULE.md @@ -17,6 +17,8 @@ Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce ### 1. Registrazione automatica Pipeline: `Sessions::Stop` → `FinalizeSession` → `UploadJob` → storage. +MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso). In pausa o con sola slate `alwaysAvailable` la registrazione è disattivata, così il replay non contiene minuti di schermo «Trasmissione in pausa». + ### 2. Retention e purge `Recordings::PurgeExpiredJob` / `rails recordings:purge_expired` @@ -86,11 +88,31 @@ bash scripts/setup_garage_production.sh # garage.prod.toml, bucket, chiavi → - `REPLAY_STORAGE_ACCESS_KEY_ID` = Key ID Garage (es. `GK...`) - `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave -## Cron consigliati (produzione) +## Cron (produzione) -```cron -0 3 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:purge_expired -0 8 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:expiry_warnings +Installazione sul server: + +```bash +cd /opt/matchlivetv/infra +bash scripts/install_production_cron.sh +``` + +Job attivi (utente `eminux`): + +| Orario | Task | +|--------|------| +| 03:00 | `recordings:purge_expired` — elimina replay scaduti (DB + Garage) | +| 08:00 | `recordings:expiry_warnings` — email avviso scadenza (7 gg) | + +Log: `/opt/matchlivetv/log/cron-replay.log` + +### Capacità Garage + +All’installazione, `setup_garage_production.sh` assegna ~**(disco libero − 20 GB)** al nodo. +Per ridimensionare dopo: + +```bash +bash scripts/garage_set_capacity.sh 85G # oppure senza argomento: calcolo automatico ``` ## API mobile diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index af57683..0b80718 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -115,6 +115,7 @@ services: condition: service_started volumes: - recordings:/recordings + - stream_overlays:/app/tmp/stream_overlays healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"] interval: 15s @@ -150,6 +151,7 @@ services: REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-} REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-} REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true} + OVERLAY_RELAY_PROCESS_CHECK: "false" depends_on: rails: condition: service_healthy @@ -157,6 +159,7 @@ services: condition: service_started volumes: - recordings:/recordings + - stream_overlays:/app/tmp/stream_overlays garage: image: dxflrs/garage:v1.0.1 @@ -172,5 +175,6 @@ volumes: postgres_data: redis_data: recordings: + stream_overlays: garage_meta: garage_data: diff --git a/infra/mediamtx.yml b/infra/mediamtx.yml index 2959ce8..707151f 100644 --- a/infra/mediamtx.yml +++ b/infra/mediamtx.yml @@ -22,11 +22,15 @@ rtmpAddress: :1935 hls: yes hlsAddress: :8888 hlsVariant: mpegts +# Allineato a slate (1s) e camera; evita salti 1s→6s che glitchano HLS.js +hlsSegmentDuration: 1s +hlsSegmentMaxSize: 50M pathDefaults: source: publisher overridePublisher: yes - record: yes + # Recording solo via job dedicato: il recorder inline crasha il muxer HLS al switch slate→live + record: false recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S recordSegmentDuration: 60s recordDeleteAfter: 48h @@ -37,9 +41,5 @@ paths: # match_{uuid}: # alwaysAvailable: true # alwaysAvailableFile: /slates/offline.mp4 - # alwaysAvailableTracks: - # - codec: H264 - # - codec: MPEG4Audio - # sampleRate: 48000 - # channelCount: 2 + # (non usare alwaysAvailableTracks insieme al file — MediaMTX >= 2026.02) all_others: diff --git a/infra/scripts/cron/run_rails_task.sh b/infra/scripts/cron/run_rails_task.sh new file mode 100755 index 0000000..b7e481c --- /dev/null +++ b/infra/scripts/cron/run_rails_task.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Esegue un task Rails in produzione (usato da crontab). +set -euo pipefail + +ROOT="${MATCHLIVETV_INFRA:-/opt/matchlivetv/infra}" +cd "$ROOT" + +exec docker compose -f docker-compose.prod.yml --env-file .env exec -T rails \ + bundle exec rails "$@" diff --git a/infra/scripts/garage_set_capacity.sh b/infra/scripts/garage_set_capacity.sh new file mode 100755 index 0000000..aa47db4 --- /dev/null +++ b/infra/scripts/garage_set_capacity.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Ridimensiona capacità Garage (layout assign + apply). Uso: garage_set_capacity.sh 85G +set -euo pipefail + +CAPACITY="${1:-}" +RESERVE_GB="${GARAGE_RESERVE_GB:-20}" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}" +ENV_FILE="${ENV_FILE:-${ROOT}/.env}" +GARAGE_CONTAINER="${GARAGE_CONTAINER:-}" + +if [ -z "$CAPACITY" ]; then + avail="$(df -BG "${ROOT}" 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G' || echo 0)" + if [ "$avail" -lt 1 ]; then + avail="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" + fi + cap_gb=$((avail - RESERVE_GB)) + [ "$cap_gb" -lt 10 ] && cap_gb=10 + CAPACITY="${cap_gb}G" + echo "Spazio disponibile ~${avail}G, riservo ${RESERVE_GB}G → capacità Garage ${CAPACITY}" +fi + +cd "$ROOT" +if [ -z "$GARAGE_CONTAINER" ]; then + GARAGE_CONTAINER="$(docker ps --format '{{.Names}}' | grep -E 'garage-1$' | head -1)" +fi +[ -n "$GARAGE_CONTAINER" ] || { echo "Container garage non trovato"; exit 1; } + +node_id="$(docker exec "$GARAGE_CONTAINER" /garage status 2>/dev/null | awk '/^[0-9a-f]{16,}/{print $1; exit}')" +[ -n "$node_id" ] || { echo "Node id non trovato"; exit 1; } + +echo "Node: $node_id → capacità $CAPACITY" +docker exec "$GARAGE_CONTAINER" /garage layout assign -z dc1 -c "$CAPACITY" "$node_id" +ver="$(docker exec "$GARAGE_CONTAINER" /garage layout show 2>/dev/null | awk '/layout version:/{print $NF; exit}')" +next=$((ver + 1)) +docker exec "$GARAGE_CONTAINER" /garage layout apply --version "$next" +docker exec "$GARAGE_CONTAINER" /garage layout show diff --git a/infra/scripts/generate_slate.sh b/infra/scripts/generate_slate.sh index 4385943..e64e2cb 100755 --- a/infra/scripts/generate_slate.sh +++ b/infra/scripts/generate_slate.sh @@ -1,13 +1,33 @@ #!/bin/sh -# Generate offline slate MP4 for MediaMTX alwaysAvailable (pausa / attesa segnale) +# Slate MP4 per MediaMTX alwaysAvailable (pausa / attesa / perdita rete). +# H.264 1280x720 30fps + AAC 48kHz mono (allineato all'app Android). set -e -OUT_DIR="$(dirname "$0")/../slates" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT_DIR="$(cd "$(dirname "$0")/../slates" && pwd)" +BRAND_IMAGE="${BRAND_IMAGE:-$ROOT/brand/CopertinaCanale_6.png}" OUT="$OUT_DIR/offline.mp4" + +if [ ! -f "$BRAND_IMAGE" ]; then + echo "Manca immagine brand: $BRAND_IMAGE" >&2 + exit 1 +fi + mkdir -p "$OUT_DIR" -docker run --rm -v "$OUT_DIR:/out" linuxserver/ffmpeg:latest \ - -f lavfi -i color=c=0x1a1a1a:s=1280x720:r=30 \ - -vf "drawtext=text='Match Live TV':fontcolor=white:fontsize=48:x=(w-text_w)/2:y=(h-text_h)/2-40,drawtext=text='Trasmissione in pausa':fontcolor=0xaaaaaa:fontsize=28:x=(w-text_w)/2:y=(h-text_h)/2+20" \ - -f lavfi -i sine=frequency=440:sample_rate=48000 \ - -t 30 -c:v libx264 -pix_fmt yuv420p -g 30 -preset fast \ - -c:a aac -b:a 128k -y /out/offline.mp4 -echo "Created $OUT" +BRAND_DIR="$(cd "$(dirname "$BRAND_IMAGE")" && pwd)" +BRAND_FILE="$(basename "$BRAND_IMAGE")" + +docker run --rm \ + -v "$OUT_DIR:/out" \ + -v "$BRAND_DIR:/brand:ro" \ + linuxserver/ffmpeg:latest \ + -loop 1 -framerate 30 -i "/brand/$BRAND_FILE" \ + -f lavfi -i anullsrc=r=48000:cl=mono \ + -filter:v "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x0a0a0e" \ + -map 0:v -map 1:a \ + -t 30 -c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.1 \ + -x264-params "keyint=30:min-keyint=30:scenecut=0:bframes=0" \ + -g 30 -keyint_min 30 -force_key_frames "expr:gte(t,n_forced*1)" \ + -preset fast \ + -c:a aac -b:a 128k -ac 1 -shortest -y /out/offline.mp4 + +echo "Created $OUT from $BRAND_IMAGE" diff --git a/infra/scripts/install_production_cron.sh b/infra/scripts/install_production_cron.sh new file mode 100755 index 0000000..1c3c60f --- /dev/null +++ b/infra/scripts/install_production_cron.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Installa crontab replay su server produzione (utente corrente). +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +RUNNER="${ROOT}/scripts/cron/run_rails_task.sh" +LOG_DIR="${MATCHLIVETV_LOG_DIR:-/opt/matchlivetv/log}" +LOG_FILE="${LOG_DIR}/cron-replay.log" + +chmod +x "$RUNNER" +mkdir -p "$LOG_DIR" + +MARKER="# matchlivetv-replay-cron" +CRON_BLOCK="${MARKER} +0 3 * * * ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 +0 8 * * * ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 +" + +existing="$(crontab -l 2>/dev/null || true)" +if echo "$existing" | grep -q "$MARKER"; then + echo "Crontab replay già presente, aggiorno..." + echo "$existing" | awk -v block="$CRON_BLOCK" ' + BEGIN { skip=0 } + /# matchlivetv-replay-cron/ { skip=1; next } + skip && /^[^#]/ && $0 !~ /^$/ { skip=0 } + skip { next } + { print } + END { print block } + ' | crontab - +else + (echo "$existing"; echo "$CRON_BLOCK") | crontab - +fi + +echo "Crontab installato:" +crontab -l | grep -A3 "$MARKER" diff --git a/infra/scripts/setup_garage_production.sh b/infra/scripts/setup_garage_production.sh index 3f8ffae..63dad06 100755 --- a/infra/scripts/setup_garage_production.sh +++ b/infra/scripts/setup_garage_production.sh @@ -14,6 +14,13 @@ bash "${ROOT}/scripts/ensure_garage_prod_config.sh" echo "Avvio Garage..." docker compose -f docker-compose.prod.yml --env-file .env up -d garage +avail_gb="$(df -BG / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')" +reserve="${GARAGE_RESERVE_GB:-20}" +cap_gb=$((avail_gb - reserve)) +[ "$cap_gb" -lt 10 ] && cap_gb=10 +export GARAGE_NODE_CAPACITY="${GARAGE_NODE_CAPACITY:-${cap_gb}G}" +echo "Capacità nodo Garage: ${GARAGE_NODE_CAPACITY} (disco ~${avail_gb}G, riserva ${reserve}G)" + bash "${ROOT}/scripts/setup_garage_replays.sh" echo "Riavvio Rails e Sidekiq..." diff --git a/infra/scripts/setup_garage_replays.sh b/infra/scripts/setup_garage_replays.sh index 58aab1f..e5c7b1f 100755 --- a/infra/scripts/setup_garage_replays.sh +++ b/infra/scripts/setup_garage_replays.sh @@ -46,7 +46,8 @@ ensure_layout() { exit 1 fi echo "Node id: $node_id" - gexec layout assign -z dc1 -c 1G "$node_id" 2>/dev/null || true + local cap="${GARAGE_NODE_CAPACITY:-1G}" + gexec layout assign -z dc1 -c "$cap" "$node_id" 2>/dev/null || true gexec layout apply --version 1 2>/dev/null || gexec layout apply 2>/dev/null || true } diff --git a/infra/slates/README.md b/infra/slates/README.md index 69a6ec3..e3322c4 100644 --- a/infra/slates/README.md +++ b/infra/slates/README.md @@ -1,6 +1,12 @@ # Slate video (offline) -Place `offline.mp4` here — H.264 + AAC, 1280x720, 30fps, 48kHz stereo. +Genera da `brand/CopertinaCanale_6.png`: + +```bash +cd infra && bash scripts/generate_slate.sh +``` + +Output: `offline.mp4` — H.264 + AAC, 1280×720, 30fps, 48 kHz mono. Must match phone encoder settings for seamless `alwaysAvailable` merge. Generate a test slate with ffmpeg: diff --git a/infra/slates/offline.mp4 b/infra/slates/offline.mp4 index c870254..12df62e 100644 Binary files a/infra/slates/offline.mp4 and b/infra/slates/offline.mp4 differ diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt index 7d10154..cab0e3a 100644 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingEngine.kt @@ -1,6 +1,7 @@ package com.matchlivetv.match_live_tv import android.content.Context +import android.media.MediaCodecInfo import android.os.Build import android.os.Handler import android.os.Looper @@ -21,8 +22,9 @@ data class StreamingConfig( val audioBitrate: Int = 128_000, val fps: Int = 30, val rotation: Int = 0, - val sampleRate: Int = 44_100, - val stereo: Boolean = true, + // Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono) + val sampleRate: Int = 48_000, + val stereo: Boolean = false, val maxReconnectAttempts: Int = 10, val reconnectDelayMs: Long = 5_000L, ) @@ -144,11 +146,14 @@ class StreamingEngine( stopPreviewAndStreamForPrepare(stream) val prepared = try { stream.prepareVideo( - cfg.width, - cfg.height, - cfg.videoBitrate, - cfg.fps, + width = cfg.width, + height = cfg.height, + bitrate = cfg.videoBitrate, + fps = cfg.fps, + iFrameInterval = 1, rotation = cfg.rotation, + profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, + level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, ) && stream.prepareAudio( cfg.sampleRate, cfg.stereo, @@ -181,6 +186,45 @@ class StreamingEngine( } } + /** Ripresa dopo pausa: stop completo poi ripubblica (evita doppio start e RTMP bloccato). */ + fun resumeStream(streamConfig: StreamingConfig) { + ensureMainThread() + config = streamConfig + lastError = null + reconnectAttempts.set(0) + stopMetricsLoop() + + genericStream?.let { stream -> + pausingIntentionally.set(true) + try { + if (stream.isStreaming) { + stream.stopStream() + } + } finally { + pausingIntentionally.set(false) + } + } + + if (state == StreamingState.STREAMING || + state == StreamingState.CONNECTING || + state == StreamingState.RECONNECTING + ) { + pauseStream() + } + + streamStartedAtMs = 0L + currentBitrateKbps = 0L + currentFps = 0 + updateState(StreamingState.PREVIEWING) + startMetricsLoop() + + mainHandler.postDelayed({ + if (!isReleased.get()) { + startStream(streamConfig) + } + }, 200) + } + fun startStream(streamConfig: StreamingConfig) { ensureMainThread() if (isReleased.get()) { @@ -204,11 +248,14 @@ class StreamingEngine( val prepared = try { stream.prepareVideo( - streamConfig.width, - streamConfig.height, - streamConfig.videoBitrate, - streamConfig.fps, + width = streamConfig.width, + height = streamConfig.height, + bitrate = streamConfig.videoBitrate, + fps = streamConfig.fps, + iFrameInterval = 1, rotation = streamConfig.rotation, + profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline, + level = MediaCodecInfo.CodecProfileLevel.AVCLevel31, ) && stream.prepareAudio( streamConfig.sampleRate, streamConfig.stereo, @@ -347,12 +394,7 @@ class StreamingEngine( override fun onNewBitrate(bitrate: Long) { postMain { currentBitrateKbps = bitrate / 1000 - genericStream?.let { stream -> - bitrateAdapter.adaptBitrate( - bitrate, - stream.getStreamClient().hasCongestion(), - ) - } + // Bitrate fissa: l'adattamento on-the-fly causa glitch e GOP instabili su MediaMTX/HLS. emitMetrics() } } @@ -470,8 +512,8 @@ class StreamingEngine( val elapsedMs = now - lastFpsSampleAtMs if (elapsedMs >= 1_000L) { - val frameDelta = sentFrames - lastVideoFrameCount - currentFps = ((frameDelta * 1000L) / elapsedMs).toInt() + val frameDelta = (sentFrames - lastVideoFrameCount).coerceAtLeast(0L) + currentFps = ((frameDelta * 1000L) / elapsedMs).toInt().coerceIn(0, 120) lastVideoFrameCount = sentFrames lastFpsSampleAtMs = now } diff --git a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt index d226295..af43f84 100644 --- a/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt +++ b/mobile/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/StreamingPlugin.kt @@ -127,6 +127,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "startStream" -> startStream(call, result) + "resumeStream" -> resumeStream(call, result) "stopStream" -> stopStream(result) "pauseStream" -> pauseStream(result) "getMetrics" -> getMetrics(result) @@ -194,6 +195,53 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh } } + private fun resumeStream(call: MethodCall, result: Result) { + if (activityBinding?.activity == null) { + result.error("no_activity", "Activity non disponibile per lo streaming", null) + return + } + + val args = call.arguments as? Map<*, *> + val rtmpUrl = args?.get("rtmpUrl") as? String + if (rtmpUrl.isNullOrBlank()) { + result.error("invalid_args", "rtmpUrl is required", null) + return + } + + val config = StreamingConfig( + rtmpUrl = rtmpUrl, + width = (args["width"] as? Number)?.toInt() ?: 1280, + height = (args["height"] as? Number)?.toInt() ?: 720, + videoBitrate = (args["videoBitrate"] as? Number)?.toInt() ?: 2_500_000, + audioBitrate = (args["audioBitrate"] as? Number)?.toInt() ?: 128_000, + fps = (args["fps"] as? Number)?.toInt() ?: 30, + rotation = (args["rotation"] as? Number)?.toInt() ?: 0, + maxReconnectAttempts = (args["maxReconnectAttempts"] as? Number)?.toInt() ?: 10, + reconnectDelayMs = (args["reconnectDelayMs"] as? Number)?.toLong() ?: 5_000L, + ) + + val engine = getOrCreateEngine() + engine.removeListener(engineListener) + engine.addListener(engineListener) + attachPreviewIfPossible() + + val intent = StreamingForegroundService.startIntent(appContext) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent) + } else { + appContext.startService(intent) + } + bindServiceIfNeeded() + + try { + engine.resumeStream(config) + result.success(true) + } catch (exception: Exception) { + appContext.startService(StreamingForegroundService.stopIntent(appContext)) + result.error("resume_failed", exception.message, null) + } + } + private fun stopStream(result: Result) { appContext.startService(StreamingForegroundService.stopIntent(appContext)) getEngine()?.let { engine -> diff --git a/mobile/lib/features/camera_mode/camera_screen.dart b/mobile/lib/features/camera_mode/camera_screen.dart index 7a1c768..7fcc26e 100644 --- a/mobile/lib/features/camera_mode/camera_screen.dart +++ b/mobile/lib/features/camera_mode/camera_screen.dart @@ -14,6 +14,7 @@ import '../../platform/streaming_channel.dart'; import '../../platform/streaming_preview.dart'; import '../../providers/match_rules_provider.dart'; import '../../providers/score_provider.dart'; +import '../../providers/score_sync_provider.dart'; import '../../shared/api_client.dart'; import '../../shared/regia_share.dart'; import '../../shared/watch_share.dart'; @@ -42,6 +43,7 @@ class _CameraScreenState extends ConsumerState { Timer? _elapsedTimer; Timer? _telemetryTimer; Timer? _statsTimer; + Timer? _scorePullTimer; int _batteryLevel = 100; int? _lastDelta; int _prevHomePoints = 0; @@ -55,6 +57,8 @@ class _CameraScreenState extends ConsumerState { StreamSubscription>? _metricsSub; Orientation? _lastOrientation; bool _pauseInFlight = false; + bool _resumeInFlight = false; + String? _lastStreamErrorShown; @override void initState() { @@ -73,6 +77,9 @@ class _CameraScreenState extends ConsumerState { }); _telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry()); _statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats()); + _scorePullTimer = Timer.periodic(const Duration(seconds: 2), (_) { + unawaited(ref.read(scoreSyncProvider).pullFromServer()); + }); } @override @@ -121,15 +128,18 @@ class _CameraScreenState extends ConsumerState { } final match = await client.fetchMatch(session.matchId); + final statusAtOpen = session.status; ref.read(sessionProvider.notifier).setSession(session, match: match); if (session.score != null) { ref.read(scoreProvider.notifier).reset(session.score!); } - // Ripresa dopo crash: solo idle va in connecting; in pausa restiamo fermi. + // idle → startSession; wizard può aver già messo connecting prima della camera. + var justStartedSession = false; if (session.status == 'idle') { session = await client.startSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(session, match: match); + justStartedSession = true; } final ws = ref.read(websocketServiceProvider); @@ -140,18 +150,41 @@ class _CameraScreenState extends ConsumerState { final fresh = await client.fetchSession(widget.sessionId); ref.read(sessionProvider.notifier).setSession(fresh, match: match); + if (fresh.score != null) { + ref.read(scoreProvider.notifier).reset(fresh.score!); + } - if (fresh.status != 'paused' && fresh.rtmpIngestUrl != null) { + // Avvio RTMP: wizard chiama startSession prima della camera → stato "connecting". + // In pausa all'apertura: niente auto-publish (serve "RIPRENDI DIRETTA"). + final wasPausedAtOpen = statusAtOpen == 'paused'; + final shouldAutoPublish = fresh.rtmpIngestUrl != null && + !wasPausedAtOpen && + fresh.status != 'paused' && + (justStartedSession || + fresh.status == 'connecting' || + fresh.status == 'live' || + fresh.status == 'reconnecting'); + if (shouldAutoPublish) { await Future.delayed(const Duration(milliseconds: 500)); - await StreamingChannel.startStream( - rtmpUrl: fresh.rtmpIngestUrl!, - targetBitrate: fresh.targetBitrate, - targetFps: 30, - ); - if (mounted && fresh.canResumeBroadcast && fresh.status != 'paused') { + final useFreshStart = + justStartedSession || fresh.status == 'connecting'; + if (useFreshStart) { + await StreamingChannel.startStream( + rtmpUrl: fresh.rtmpIngestUrl!, + targetBitrate: fresh.targetBitrate, + targetFps: 30, + ); + } else { + await StreamingChannel.resumeStream( + rtmpUrl: fresh.rtmpIngestUrl!, + targetBitrate: fresh.targetBitrate, + targetFps: 30, + ); + } + if (mounted && (justStartedSession || fresh.status == 'connecting')) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Diretta ripresa — stai di nuovo in onda'), + content: Text('Diretta avviata — connessione al server…'), duration: Duration(seconds: 3), ), ); @@ -166,10 +199,25 @@ class _CameraScreenState extends ConsumerState { final metrics = Map.from( (event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {}, ); + final streamState = metrics['state'] as String?; + final lastError = metrics['lastError'] as String?; setState(() { _bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000; _fps = (metrics['fps'] as num?)?.toInt() ?? 0; }); + if (lastError != null && + lastError.isNotEmpty && + lastError != _lastStreamErrorShown && + (streamState == 'ERROR' || streamState == 'RECONNECTING') && + mounted) { + _lastStreamErrorShown = lastError; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Connessione RTMP: $lastError'), + duration: const Duration(seconds: 5), + ), + ); + } }); } on PlatformException catch (e) { if (mounted) { @@ -339,7 +387,39 @@ class _CameraScreenState extends ConsumerState { } catch (_) {} } + Future _applyRemoteResume() async { + if (_resumeInFlight) return; + final session = ref.read(sessionProvider).session; + if (session == null || session.rtmpIngestUrl == null) return; + _resumeInFlight = true; + try { + await StreamingChannel.resumeStream( + rtmpUrl: session.rtmpIngestUrl!, + targetBitrate: session.targetBitrate, + targetFps: 30, + ); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Ripresa dalla regia — di nuovo in onda'), + duration: Duration(seconds: 3), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Ripresa RTMP: $e')), + ); + } + } finally { + _resumeInFlight = false; + } + } + Future _resumeStream() async { + if (_resumeInFlight) return; + _resumeInFlight = true; try { final match = ref.read(sessionProvider).match; var session = @@ -347,7 +427,7 @@ class _CameraScreenState extends ConsumerState { ref.read(sessionProvider.notifier).setSession(session, match: match); if (session.rtmpIngestUrl != null) { - await StreamingChannel.startStream( + await StreamingChannel.resumeStream( rtmpUrl: session.rtmpIngestUrl!, targetBitrate: session.targetBitrate, targetFps: 30, @@ -371,6 +451,8 @@ class _CameraScreenState extends ConsumerState { SnackBar(content: Text('Ripresa diretta: $e')), ); } + } finally { + _resumeInFlight = false; } } @@ -413,6 +495,7 @@ class _CameraScreenState extends ConsumerState { _elapsedTimer?.cancel(); _telemetryTimer?.cancel(); _statsTimer?.cancel(); + _scorePullTimer?.cancel(); _metricsSub?.cancel(); WakelockPlus.disable(); _unlockOrientation(); @@ -427,6 +510,11 @@ class _CameraScreenState extends ConsumerState { if (next == 'paused' && previous != 'paused' && !_pauseInFlight) { _applyRemotePause(); } + if (previous == 'paused' && + (next == 'connecting' || next == 'live') && + !_resumeInFlight) { + _applyRemoteResume(); + } if (next == 'ended' && previous != 'ended' && mounted) { _leaveSession(); } @@ -459,6 +547,26 @@ class _CameraScreenState extends ConsumerState { platformLabel: isPaused ? 'PAUSA' : 'LIVE', ), ), + if (isPaused) + Positioned( + left: 20, + right: 20, + bottom: isLandscape ? 100 : 200, + child: Center( + child: FilledButton.icon( + onPressed: _resumeStream, + icon: const Icon(Icons.play_arrow, size: 28), + label: const Text( + 'RIPRENDI DIRETTA', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + style: FilledButton.styleFrom( + backgroundColor: AppTheme.primaryRed, + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16), + ), + ), + ), + ), if (isLandscape) _LandscapeOverlay( elapsed: sessionState.elapsed, diff --git a/mobile/lib/platform/streaming_channel.dart b/mobile/lib/platform/streaming_channel.dart index 1c3c343..830a96c 100644 --- a/mobile/lib/platform/streaming_channel.dart +++ b/mobile/lib/platform/streaming_channel.dart @@ -35,6 +35,19 @@ class StreamingChannel { }); } + /// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING). + static Future resumeStream({ + required String rtmpUrl, + int targetBitrate = 2500000, + int targetFps = 30, + }) async { + await _channel.invokeMethod('resumeStream', { + 'rtmpUrl': rtmpUrl, + 'videoBitrate': targetBitrate, + 'fps': targetFps, + }); + } + static Future stopStream() async { await _channel.invokeMethod('stopStream'); } diff --git a/mobile/lib/providers/score_sync_provider.dart b/mobile/lib/providers/score_sync_provider.dart new file mode 100644 index 0000000..162a55f --- /dev/null +++ b/mobile/lib/providers/score_sync_provider.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../shared/api_client.dart'; +import '../shared/models/score_state.dart'; +import 'score_provider.dart'; +import 'session_provider.dart'; + +/// Coda sync HTTP: evita richieste fuori ordine e allinea l'app al DB (pagina live). +class ScoreSyncService { + ScoreSyncService(this._ref); + + final Ref _ref; + bool _busy = false; + bool _dirty = false; + DateTime? _suppressStaleRemoteUntil; + + /// Ignora broadcast WebSocket obsoleti subito dopo un push HTTP. + bool acceptCableScore(ScoreState remote) { + final until = _suppressStaleRemoteUntil; + if (until == null || DateTime.now().isAfter(until)) return true; + final local = _ref.read(scoreProvider); + return !_isClearlyBehind(remote, local); + } + + bool _isClearlyBehind(ScoreState remote, ScoreState local) { + final r = remote.homeSets * 1000 + + remote.awaySets * 1000 + + remote.homePoints + + remote.awayPoints; + final l = local.homeSets * 1000 + + local.awaySets * 1000 + + local.homePoints + + local.awayPoints; + return r < l; + } + + Future push() async { + _dirty = true; + if (_busy) return; + _busy = true; + while (_dirty) { + _dirty = false; + final sessionId = _ref.read(sessionProvider).session?.id; + if (sessionId == null) continue; + + final payload = _ref.read(scoreProvider).toCablePayload(); + try { + final remote = await _ref.read(apiClientProvider).syncScore(sessionId, payload); + _suppressStaleRemoteUntil = DateTime.now().add(const Duration(milliseconds: 500)); + if (remote != null) { + _ref.read(scoreProvider.notifier).applyRemote(remote); + } + } catch (_) { + // Mantieni stato locale; il prossimo pull periodico riallinea se possibile. + } + } + _busy = false; + } + + Future pullFromServer() async { + if (_busy) return; + final sessionId = _ref.read(sessionProvider).session?.id; + if (sessionId == null) return; + try { + final session = await _ref.read(apiClientProvider).fetchSession(sessionId); + if (session.score != null) { + _ref.read(scoreProvider.notifier).applyRemote(session.score!); + } + } catch (_) {} + } +} + +final scoreSyncProvider = Provider((ref) { + return ScoreSyncService(ref); +}); diff --git a/mobile/lib/providers/session_provider.dart b/mobile/lib/providers/session_provider.dart index b2ade27..46809bb 100644 --- a/mobile/lib/providers/session_provider.dart +++ b/mobile/lib/providers/session_provider.dart @@ -73,11 +73,17 @@ class SessionNotifier extends StateNotifier { } if (device.status != null && state.session != null) { final s = state.session!; + final incoming = device.status!; + // Non sovrascrivere una pausa intenzionale con stati transitori (live/connecting). + if (s.status == 'paused' && incoming != 'paused' && incoming != 'ended') { + state = state.copyWith(cameraDevice: device); + return; + } state = state.copyWith( session: StreamSession( id: s.id, matchId: s.matchId, - status: device.status!, + status: incoming, platform: s.platform, rtmpIngestUrl: s.rtmpIngestUrl, hlsPlaybackUrl: s.hlsPlaybackUrl, diff --git a/mobile/lib/shared/api_client.dart b/mobile/lib/shared/api_client.dart index 8575799..4aa4479 100644 --- a/mobile/lib/shared/api_client.dart +++ b/mobile/lib/shared/api_client.dart @@ -6,6 +6,7 @@ import '../providers/auth_provider.dart'; import 'models/invite_preview.dart'; import 'models/match.dart'; import 'models/recording_item.dart'; +import 'models/score_state.dart'; import 'models/stream_session.dart'; import 'models/team.dart'; import 'models/user.dart'; @@ -210,6 +211,19 @@ class ApiClient { return StreamSession.fromJson(response.data!); } + /// Persiste il tabellone e restituisce lo stato salvato sul server. + Future syncScore(String sessionId, Map payload) async { + final response = await _dio.patch>( + '/sessions/$sessionId/score', + data: payload, + ); + final score = response.data?['score']; + if (score is Map) { + return ScoreState.fromJson(score); + } + return null; + } + Future createRegiaLink(String sessionId) async { final response = await _dio.post>( '/sessions/$sessionId/regia_link', diff --git a/mobile/lib/shared/websocket_service.dart b/mobile/lib/shared/websocket_service.dart index c7b8354..5324677 100644 --- a/mobile/lib/shared/websocket_service.dart +++ b/mobile/lib/shared/websocket_service.dart @@ -6,6 +6,7 @@ import 'package:x_action_cable_v2/x_action_cable.dart'; import '../core/config.dart'; import '../providers/auth_provider.dart'; import '../providers/score_provider.dart'; +import '../providers/score_sync_provider.dart'; import '../providers/session_provider.dart'; import 'models/device_state.dart'; import 'models/score_state.dart'; @@ -88,7 +89,10 @@ class WebsocketService { switch (type) { case 'score_update': - _ref.read(scoreProvider.notifier).applyRemote(ScoreState.fromJson(data)); + final remote = ScoreState.fromJson(data); + if (_ref.read(scoreSyncProvider).acceptCableScore(remote)) { + _ref.read(scoreProvider.notifier).applyRemote(remote); + } break; case 'device_state': _ref @@ -109,6 +113,14 @@ class WebsocketService { _ref.read(sessionProvider.notifier).applyCommand(action); } break; + case 'stream_event': + final event = data['event'] as String?; + if (event == 'paused') { + _ref.read(sessionProvider.notifier).applyCommand('pause_stream'); + } else if (event == 'resumed') { + _ref.read(sessionProvider.notifier).applyCommand('resume_stream'); + } + break; default: break; } diff --git a/mobile/lib/shared/widgets/live_score_controls.dart b/mobile/lib/shared/widgets/live_score_controls.dart index 2b774da..e5176f2 100644 --- a/mobile/lib/shared/widgets/live_score_controls.dart +++ b/mobile/lib/shared/widgets/live_score_controls.dart @@ -1,10 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../app/theme.dart'; import '../../features/shared/live_score_actions.dart'; import '../../providers/score_provider.dart'; -import '../websocket_service.dart'; +import '../../providers/score_sync_provider.dart'; import 'score_overlay_bar.dart'; /// Tabellone interattivo: aggiorna punteggio via WebSocket (senza link regia). @@ -27,8 +29,8 @@ class LiveScoreControls extends ConsumerWidget { final Future Function()? onStopStream; void _sync(WidgetRef ref) { - if (!ref.read(websocketServiceProvider).isConnected) return; - ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider)); + // HTTP serializzato; il server fa broadcast WebSocket. Pagina live = status.json. + unawaited(ref.read(scoreSyncProvider).push()); } Future _pointHome(BuildContext context, WidgetRef ref) async { diff --git a/scripts/test/adb_phone_stream_e2e.sh b/scripts/test/adb_phone_stream_e2e.sh new file mode 100755 index 0000000..045e901 --- /dev/null +++ b/scripts/test/adb_phone_stream_e2e.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# E2E streaming: installa APK, login demo, apre camera sessione attiva. +# Richiede: USB debug + su MIUI/Xiaomi abilitare "Installazione via USB". +set -euo pipefail + +export PATH="$HOME/flutter/bin:$HOME/Android/Sdk/platform-tools:$PATH" +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +DEVICE="${ANDROID_DEVICE:-$(adb devices | awk '/device$/{print $1; exit}')}" +SESSION_ID="${SESSION_ID:-fe09a29f-79db-44d5-864f-397942c0a2c5}" +EMAIL="${TEST_EMAIL:-coach@matchlivetv.test}" +PASSWORD="${TEST_PASSWORD:-password123}" +APK="$ROOT/mobile/build/app/outputs/flutter-apk/app-release.apk" +PKG="com.matchlivetv.match_live_tv" + +if [[ -z "$DEVICE" ]]; then + echo "Nessun telefono USB. Collega il device e abilita debug USB." + exit 1 +fi + +echo "== Device: $DEVICE ==" + +if [[ ! -f "$APK" ]]; then + echo "Build APK release..." + (cd "$ROOT/mobile" && flutter build apk --release --dart-define=API_BASE_URL=https://www.matchlivetv.it) +fi + +echo "== Install APK (conferma sul telefono se richiesto) ==" +if ! adb -s "$DEVICE" install -r -g "$APK"; then + echo "" + echo "Installazione bloccata. Su Xiaomi/MIUI:" + echo " Impostazioni → Impostazioni aggiuntive → Opzioni sviluppatore → Installazione via USB → Attiva" + echo "Poi rilancia questo script." + exit 1 +fi + +echo "== Grant permissions ==" +adb -s "$DEVICE" shell pm grant "$PKG" android.permission.CAMERA 2>/dev/null || true +adb -s "$DEVICE" shell pm grant "$PKG" android.permission.RECORD_AUDIO 2>/dev/null || true +adb -s "$DEVICE" shell pm grant "$PKG" android.permission.POST_NOTIFICATIONS 2>/dev/null || true + +echo "== Launch app ==" +adb -s "$DEVICE" shell am force-stop "$PKG" +adb -s "$DEVICE" shell am start -n "$PKG/.MainActivity" +sleep 4 + +tap_text() { + local text="$1" + adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true + local coords + coords=$(adb -s "$DEVICE" shell "grep -o \"text=\\\"$text\\\"[^>]*bounds=\\\"\\[[0-9,]*\\]\\[[0-9,]*\\]\\\"\" /sdcard/ui.xml 2>/dev/null | head -1" || true) + if [[ -z "$coords" ]]; then return 1; fi + local b + b=$(echo "$coords" | sed -n 's/.*bounds=\"\[\([div0-9,]*\)\]\[\([0-9,]*\)\]\".*/\1 \2/p') + local x1 y1 x2 y2 + IFS=',' read -r x1 y1 <<< "${b%% *}" + IFS=',' read -r x2 y2 <<< "${b##* }" + local cx=$(( (x1 + x2) / 2 )) + local cy=$(( (y1 + y2) / 2 )) + adb -s "$DEVICE" shell input tap "$cx" "$cy" + return 0 +} + +tap_contains() { + local needle="$1" + adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true + local line + line=$(adb -s "$DEVICE" shell "grep -i '$needle' /sdcard/ui.xml | head -1" || true) + [[ -n "$line" ]] || return 1 + local bounds + bounds=$(echo "$line" | sed -n 's/.*bounds=\"\[\([0-9,]*\)\]\[\([0-9,]*\)\]\".*/\1 \2/p') + local x1 y1 x2 y2 + IFS=',' read -r x1 y1 <<< "${bounds%% *}" + IFS=',' read -r x2 y2 <<< "${bounds##* }" + adb -s "$DEVICE" shell input tap $(( (x1 + x2) / 2 )) $(( (y1 + y2) / 2 )) +} + +echo "== Login (demo) ==" +# Email field +adb -s "$DEVICE" shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true +sleep 1 +# Tap center-left for email field (Flutter fallback coords 720p) +adb -s "$DEVICE" shell input tap 540 520 +adb -s "$DEVICE" shell input keyevent KEYCODE_MOVE_END +adb -s "$DEVICE" shell input keyevent --longpress $(printf '%s' "$EMAIL" | od -An -tuC | tr -s ' ' '\n' | while read -r c; do echo "KEYCODE_DEL"; done | head -40) 2>/dev/null || true +adb -s "$DEVICE" shell input text "${EMAIL//@/%40}" +sleep 1 +tap_contains "Accedi" || tap_contains "Entra" || adb -s "$DEVICE" shell input tap 540 900 +sleep 5 + +echo "== Open camera for session $SESSION_ID ==" +# Deep link via am start with route — apri wizard/camera se già loggato +adb -s "$DEVICE" shell am start -a android.intent.action.VIEW \ + -d "https://www.matchlivetv.it/live/$SESSION_ID" "$PKG" 2>/dev/null || true +sleep 3 +tap_contains "Riprendi" || tap_contains "RIPRENDI" || tap_contains "camera" || true +sleep 2 + +echo "== Session status ==" +curl -sS "https://www.matchlivetv.it/live/$SESSION_ID/status.json" | python3 -m json.tool | head -20 + +echo "" +echo "Apri nel browser: https://www.matchlivetv.it/live/$SESSION_ID" +echo "Controlla bitrate/fps dispositivo via API sessions." diff --git a/scripts/test/live_e2e_cycle.sh b/scripts/test/live_e2e_cycle.sh new file mode 100755 index 0000000..c6ae736 --- /dev/null +++ b/scripts/test/live_e2e_cycle.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# E2E ciclo diretta web: status, HLS, pausa, ripresa. +set -euo pipefail + +SESSION_ID="${SESSION_ID:-fe09a29f-79db-44d5-864f-397942c0a2c5}" +API="${API:-https://www.matchlivetv.it}" +EMAIL="${TEST_EMAIL:-coach@matchlivetv.test}" +PASSWORD="${TEST_PASSWORD:-password123}" + +fail() { echo "FAIL: $*"; exit 1; } +ok() { echo "OK: $*"; } + +echo "== Login ==" +TOKEN=$(curl -sf -X POST "$API/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])") +AUTH="Authorization: Bearer $TOKEN" + +echo "== Status iniziale ==" +INIT=$(curl -sf "$API/live/$SESSION_ID/status.json") +echo "$INIT" | python3 -m json.tool +python3 -c " +import json,sys +d=json.loads('''$INIT''') +assert not d.get('stream_closed'), d +print('status', d.get('status'), 'on_air', d.get('on_air'), 'publisher', d.get('publisher_online')) +" + +echo "== HLS master ==" +MASTER=$(curl -sf "$API/hls/live/match_${SESSION_ID}/index.m3u8") +echo "$MASTER" | head -8 +echo "$MASTER" | grep -q main_stream || fail "master senza variant" +VARIANT_LINE=$(echo "$MASTER" | grep main_stream | head -1 | tr -d '\r') +VARIANT_URL="$API/hls/live/match_${SESSION_ID}/${VARIANT_LINE}" +MEDIA=$(curl -sf "$VARIANT_URL") +SEG=$(echo "$MEDIA" | grep '_main_seg' | tail -1 | tr -d '\r') +[[ -n "$SEG" ]] || fail "nessun segmento in playlist" +SEG_URL="$API/hls/live/match_${SESSION_ID}/${SEG}" +SZ=$(curl -sf -o /tmp/live_e2e_seg.ts -w '%{size_download}' "$SEG_URL") +[[ "${SZ:-0}" -gt 1000 ]] || fail "segmento troppo piccolo ($SZ bytes)" +ok "HLS segmento ${SZ} bytes" + +if [[ "$(echo "$INIT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('paused'))")" == "True" ]]; then + echo "== Sessione in pausa: test resume ==" + curl -sf -X PATCH -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID/resume" >/dev/null + sleep 6 +fi + +echo "== Verifica live (publisher atteso) ==" +for i in 1 2 3 4 5 6 7 8 9 10; do + ST=$(curl -sf "$API/live/$SESSION_ID/status.json") + PUB=$(echo "$ST" | python3 -c "import sys,json; print(json.load(sys.stdin).get('publisher_online'))") + PAUSED=$(echo "$ST" | python3 -c "import sys,json; print(json.load(sys.stdin).get('paused'))") + echo " poll $i paused=$PAUSED publisher_online=$PUB" + if [[ "$PAUSED" == "False" && "$PUB" == "True" ]]; then + ok "live attivo" + break + fi + sleep 2 + if [[ "$i" == "10" ]]; then + echo "WARN: live non confermato entro 20s (continuo pausa test)" + fi +done + +echo "== Pausa API ==" +curl -sf -X PATCH -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID/pause" >/dev/null +sleep 8 +PAUSE_ST=$(curl -sf "$API/live/$SESSION_ID/status.json") +echo "$PAUSE_ST" | python3 -m json.tool | head -12 +python3 -c " +import json,sys +d=json.loads('''$PAUSE_ST''') +assert d.get('paused') or d.get('status')=='paused', d +print('paused OK') +" + +echo "== Ripresa API ==" +curl -sf -X PATCH -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID/resume" >/dev/null +sleep 8 +RES=$(curl -sf "$API/live/$SESSION_ID/status.json") +echo "$RES" | python3 -m json.tool | head -12 +python3 -c " +import json,sys +d=json.loads('''$RES''') +assert not d.get('paused'), d +assert d.get('status') in ('live','connecting'), d +print('resume OK') +" + +echo "== Telemetry device ==" +curl -sf -H "$AUTH" "$API/api/v1/sessions/$SESSION_ID" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); dev=d.get('devices') or []; print('devices', dev[:2])" + +ok "Ciclo E2E API/HLS completato" diff --git a/scripts/test/rtmp_test_pattern.sh b/scripts/test/rtmp_test_pattern.sh new file mode 100755 index 0000000..9ff747a --- /dev/null +++ b/scripts/test/rtmp_test_pattern.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Pubblica un pattern colorato su RTMP per testare il player web (senza telefono). +# Uso: +# SESSION_ID=fe09a29f-79db-44d5-864f-397942c0a2c5 ./scripts/test/rtmp_test_pattern.sh +# RTMP_URL=rtmp://www.matchlivetv.it:1935/live/match_UUID ./scripts/test/rtmp_test_pattern.sh +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +OUT_DIR="${TMPDIR:-/tmp}/matchlivetv-test" +PATTERN="$OUT_DIR/test_pattern.mp4" +DURATION="${DURATION_SEC:-0}" # 0 = loop infinito + +if [[ -z "${RTMP_URL:-}" ]]; then + if [[ -z "${SESSION_ID:-}" ]]; then + echo "Imposta SESSION_ID o RTMP_URL" + exit 1 + fi + RTMP_URL="rtmp://www.matchlivetv.it:1935/live/match_${SESSION_ID}" +fi + +mkdir -p "$OUT_DIR" + +if [[ ! -f "$PATTERN" ]]; then + echo "Genero video di test $PATTERN ..." + ffmpeg -y -hide_banner -loglevel warning \ + -f lavfi -i "testsrc2=size=1280x720:rate=30" \ + -f lavfi -i "sine=frequency=880:sample_rate=48000" \ + -t 30 \ + -c:v libx264 -pix_fmt yuv420p -profile:v baseline -level 3.1 -g 30 -keyint_min 30 \ + -c:a aac -b:a 128k -ac 1 \ + "$PATTERN" +fi + +echo "Pubblico su $RTMP_URL (Ctrl+C per fermare)" +ARGS=(-re -stream_loop -1 -i "$PATTERN" -c copy -f flv "$RTMP_URL") +if [[ "$DURATION" != "0" ]]; then + ARGS=(-re -t "$DURATION" -i "$PATTERN" -c copy -f flv "$RTMP_URL") +fi + +exec ffmpeg -hide_banner "${ARGS[@]}"