Aggiunge overlay server-side burn-in e stabilizza diretta live.
Tabellone, badge e brand sono ricodificati nel flusso _air via OverlayRelay; sync punteggio HTTP, volume condiviso rails/sidekiq, qualità 720p migliorata e badge CONNECTING in ripresa da pausa. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
FROM ruby:3.3-bookworm
|
FROM ruby:3.3-bookworm
|
||||||
|
|
||||||
RUN apt-get update -qq && \
|
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/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -59,16 +59,7 @@ class SessionChannel < ApplicationCable::Channel
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update_score(session, data)
|
def update_score(session, data)
|
||||||
score = session.score_state || session.create_score_state!
|
Scoring::SyncState.new(session: session, payload: data).call
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_timeout(session, data)
|
def update_timeout(session, data)
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ module Api
|
|||||||
render json: session_json(@session)
|
render json: session_json(@session)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def score
|
||||||
|
Scoring::SyncState.new(session: @session, payload: score_sync_params).call
|
||||||
|
render json: session_json(@session, detail: true)
|
||||||
|
end
|
||||||
|
|
||||||
def events
|
def events
|
||||||
events = @session.stream_events.recent.limit(100)
|
events = @session.stream_events.recent.limit(100)
|
||||||
render json: events.map { |e| event_json(e) }
|
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)
|
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||||
end
|
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)
|
def session_json(session, detail: false)
|
||||||
data = {
|
data = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
|
|||||||
34
backend/app/controllers/concerns/mediamtx_playback.rb
Normal file
34
backend/app/controllers/concerns/mediamtx_playback.rb
Normal file
@@ -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
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
module Public
|
module Public
|
||||||
class LiveController < SiteBaseController
|
class LiveController < SiteBaseController
|
||||||
|
include MediamtxPlayback
|
||||||
|
|
||||||
layout "marketing_live"
|
layout "marketing_live"
|
||||||
|
|
||||||
ACTIVE_STATUSES = %w[live connecting reconnecting].freeze
|
ACTIVE_STATUSES = %w[live connecting reconnecting].freeze
|
||||||
@@ -40,31 +42,41 @@ module Public
|
|||||||
|
|
||||||
def show
|
def show
|
||||||
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
|
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
|
||||||
|
@session = Mediamtx::PublisherSync.new(@session).call unless @session.terminal?
|
||||||
@match = @session.match
|
@match = @session.match
|
||||||
@stream_closed = @session.terminal?
|
@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
|
end
|
||||||
|
|
||||||
def status
|
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 = StreamSession.includes(match: { team: :club }).find(params[:id])
|
||||||
|
session = Mediamtx::PublisherSync.new(session).call unless session.terminal?
|
||||||
closed = session.terminal?
|
closed = session.terminal?
|
||||||
|
publisher_online = !closed && mediamtx_publisher_online?(session)
|
||||||
|
playable = !closed && mediamtx_stream_playable?(session)
|
||||||
render json: {
|
render json: {
|
||||||
status: session.status,
|
status: session.status,
|
||||||
|
paused: session.paused?,
|
||||||
stream_closed: closed,
|
stream_closed: closed,
|
||||||
live: !closed && (session.live? || mediamtx_online?(session)),
|
live: !closed && (
|
||||||
on_air: !closed && mediamtx_online?(session),
|
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,
|
ended_at: session.ended_at,
|
||||||
home_name: session.match.team.name,
|
home_name: session.match.team.name,
|
||||||
away_name: session.match.opponent_name,
|
away_name: session.match.opponent_name,
|
||||||
score: session.score_state&.as_cable_payload
|
score: session.score_state&.as_cable_payload
|
||||||
}
|
}
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
module Public
|
module Public
|
||||||
class RegiaController < SiteBaseController
|
class RegiaController < SiteBaseController
|
||||||
include Public::LiveHelper
|
include Public::LiveHelper
|
||||||
|
include MediamtxPlayback
|
||||||
|
|
||||||
layout "regia"
|
layout "regia"
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ module Public
|
|||||||
)
|
)
|
||||||
@rules = Scoring::Rules.from_match(@match)
|
@rules = Scoring::Rules.from_match(@match)
|
||||||
@stream_closed = @session.terminal?
|
@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_url = request.original_url
|
||||||
@share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
|
@share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
|
||||||
end
|
end
|
||||||
@@ -61,12 +62,14 @@ module Public
|
|||||||
def status_payload
|
def status_payload
|
||||||
score = @session.score_state
|
score = @session.score_state
|
||||||
closed = @session.terminal?
|
closed = @session.terminal?
|
||||||
|
playable = !closed && mediamtx_stream_playable?(@session)
|
||||||
{
|
{
|
||||||
status: @session.status,
|
status: @session.status,
|
||||||
paused: @session.paused?,
|
paused: @session.paused?,
|
||||||
stream_closed: closed,
|
stream_closed: closed,
|
||||||
live: !closed && (@session.live? || mediamtx_online?(@session)),
|
live: !closed && (@session.live? || @session.paused? || mediamtx_publisher_online?(@session)),
|
||||||
on_air: !closed && mediamtx_online?(@session),
|
on_air: playable,
|
||||||
|
publisher_online: !closed && mediamtx_publisher_online?(@session),
|
||||||
ended_at: @session.ended_at,
|
ended_at: @session.ended_at,
|
||||||
home_name: @session.match.team.name,
|
home_name: @session.match.team.name,
|
||||||
away_name: @session.match.opponent_name,
|
away_name: @session.match.opponent_name,
|
||||||
@@ -74,10 +77,5 @@ module Public
|
|||||||
score: score&.as_cable_payload
|
score: score&.as_cable_payload
|
||||||
}
|
}
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -61,8 +61,27 @@ module Public
|
|||||||
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
"#{match.team.name} #{score_state.home_points} - #{score_state.away_points} #{match.opponent_name}"
|
||||||
end
|
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
|
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)
|
def format_set_partial_entry(partial)
|
||||||
data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial
|
data = partial.respond_to?(:with_indifferent_access) ? partial.with_indifferent_access : partial
|
||||||
set_no = data[:set] || data["set"]
|
set_no = data[:set] || data["set"]
|
||||||
|
|||||||
31
backend/app/jobs/overlay_refresh_job.rb
Normal file
31
backend/app/jobs/overlay_refresh_job.rb
Normal file
@@ -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
|
||||||
@@ -54,11 +54,11 @@ class StreamSession < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
event :pause do
|
event :pause do
|
||||||
transitions from: %i[live reconnecting], to: :paused
|
transitions from: %i[live reconnecting connecting], to: :paused
|
||||||
end
|
end
|
||||||
|
|
||||||
event :resume do
|
event :resume do
|
||||||
transitions from: :paused, to: :live
|
transitions from: :paused, to: :connecting
|
||||||
end
|
end
|
||||||
|
|
||||||
event :finish do
|
event :finish do
|
||||||
@@ -81,13 +81,30 @@ class StreamSession < ApplicationRecord
|
|||||||
"live/match_#{id}"
|
"live/match_#{id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def mediamtx_overlay_path_name
|
||||||
|
"#{mediamtx_path_name}_air"
|
||||||
|
end
|
||||||
|
|
||||||
def matchlivetv_platform?
|
def matchlivetv_platform?
|
||||||
platform == "matchlivetv"
|
platform == "matchlivetv"
|
||||||
end
|
end
|
||||||
|
|
||||||
def hls_playback_url
|
def hls_playback_url
|
||||||
base = MatchLiveTv.hls_public_url.chomp("/")
|
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
|
end
|
||||||
|
|
||||||
def watch_page_url
|
def watch_page_url
|
||||||
|
|||||||
@@ -12,28 +12,32 @@ module Mediamtx
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_path(session)
|
def create_overlay_path(session)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_overlay_path_name
|
||||||
record = session.match.team.entitlements.recording_enabled_for_mediamtx?
|
|
||||||
body = {
|
body = {
|
||||||
source: "publisher",
|
source: "publisher",
|
||||||
overridePublisher: true,
|
overridePublisher: true,
|
||||||
alwaysAvailable: true,
|
record: false
|
||||||
alwaysAvailableFile: slate_file_path,
|
|
||||||
alwaysAvailableTracks: slate_tracks,
|
|
||||||
record: record,
|
|
||||||
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
|
|
||||||
recordSegmentDuration: "60s"
|
|
||||||
}
|
}
|
||||||
if record
|
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||||
retention_hours = session.match.team.entitlements.recording_retention_days * 24 + 48
|
unless response.success?
|
||||||
body[:recordDeleteAfter] = "#{retention_hours}h"
|
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||||
|
raise Error, "MediaMTX overlay path create failed: #{response.status} #{err}"
|
||||||
end
|
end
|
||||||
unless session.matchlivetv_platform?
|
true
|
||||||
body[:runOnReady] = run_on_ready_script(session)
|
|
||||||
body[:runOnReadyRestart] = true
|
|
||||||
body[:runOnNotReady] = webhook_curl(session, "disconnect")
|
|
||||||
end
|
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)
|
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||||
unless response.success?
|
unless response.success?
|
||||||
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||||
@@ -44,18 +48,29 @@ module Mediamtx
|
|||||||
|
|
||||||
def delete_path(session)
|
def delete_path(session)
|
||||||
delete_path_name(session.mediamtx_path_name)
|
delete_path_name(session.mediamtx_path_name)
|
||||||
|
delete_path_name(session.mediamtx_overlay_path_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_path_name(path_name)
|
def delete_path_name(path_name)
|
||||||
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
|
@conn.post("/v3/config/paths/delete/#{CGI.escape(path_name)}")
|
||||||
end
|
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)
|
def patch_path(session)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
body = {
|
body = {
|
||||||
alwaysAvailable: true,
|
alwaysAvailable: true,
|
||||||
alwaysAvailableFile: slate_file_path,
|
alwaysAvailableFile: slate_file_path
|
||||||
alwaysAvailableTracks: slate_tracks
|
|
||||||
}
|
}
|
||||||
response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body)
|
response = @conn.patch("/v3/config/paths/patch/#{CGI.escape(path)}", body)
|
||||||
unless response.success?
|
unless response.success?
|
||||||
@@ -81,6 +96,21 @@ module Mediamtx
|
|||||||
|
|
||||||
private
|
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)
|
def publish_webhook(session)
|
||||||
webhook_curl(session, "connect")
|
webhook_curl(session, "connect")
|
||||||
end
|
end
|
||||||
@@ -122,12 +152,5 @@ module Mediamtx
|
|||||||
def slate_file_path
|
def slate_file_path
|
||||||
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
|
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
|
||||||
end
|
end
|
||||||
|
|
||||||
def slate_tracks
|
|
||||||
[
|
|
||||||
{ codec: "H264" },
|
|
||||||
{ codec: "MPEG4Audio", sampleRate: 48_000, channelCount: 2 }
|
|
||||||
]
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
51
backend/app/services/mediamtx/publisher_sync.rb
Normal file
51
backend/app/services/mediamtx/publisher_sync.rb
Normal file
@@ -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
|
||||||
@@ -64,8 +64,11 @@ module Recordings
|
|||||||
@merged_temp_path = File.join(Dir.tmpdir, "replay-#{@session.id}-#{SecureRandom.hex(4)}.mp4")
|
@merged_temp_path = File.join(Dir.tmpdir, "replay-#{@session.id}-#{SecureRandom.hex(4)}.mp4")
|
||||||
list_path = "#{@merged_temp_path}.txt"
|
list_path = "#{@merged_temp_path}.txt"
|
||||||
File.write(list_path, source_files.map { |f| "file '#{f.gsub("'", "'\\''")}'" }.join("\n"))
|
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,
|
success = system(
|
||||||
out: File::NULL, err: File::NULL)
|
"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)
|
FileUtils.rm_f(list_path)
|
||||||
raise Error, "Impossibile unire i segmenti video" unless success && File.exist?(@merged_temp_path)
|
raise Error, "Impossibile unire i segmenti video" unless success && File.exist?(@merged_temp_path)
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ module Scoring
|
|||||||
|
|
||||||
def broadcast(score)
|
def broadcast(score)
|
||||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||||
|
Streams::Overlay::Refresh.call(@session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
28
backend/app/services/scoring/sync_state.rb
Normal file
28
backend/app/services/scoring/sync_state.rb
Normal file
@@ -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
|
||||||
@@ -36,7 +36,11 @@ module Sessions
|
|||||||
StreamSession.transaction do
|
StreamSession.transaction do
|
||||||
session.save!
|
session.save!
|
||||||
session.create_score_state!
|
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 })
|
log_event(session, "pairing", { created: true, platform: session.platform })
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -70,6 +74,20 @@ module Sessions
|
|||||||
session.rtmp_url = broadcast[:rtmp_url]
|
session.rtmp_url = broadcast[:rtmp_url]
|
||||||
end
|
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)
|
def log_event(session, type, metadata)
|
||||||
session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current)
|
session.stream_events.create!(event_type: type, metadata: metadata, occurred_at: Time.current)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ module Sessions
|
|||||||
def call
|
def call
|
||||||
cancel_timeout_job
|
cancel_timeout_job
|
||||||
@session.pause! if @session.may_pause?
|
@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")
|
log_event("paused")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "pause_stream" })
|
||||||
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" })
|
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "paused" })
|
||||||
|
Streams::Overlay::Refresh.call(@session)
|
||||||
@session
|
@session
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -23,12 +24,6 @@ module Sessions
|
|||||||
@session.update!(timeout_job_id: nil)
|
@session.update!(timeout_job_id: nil)
|
||||||
end
|
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)
|
def log_event(type)
|
||||||
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
|
@session.stream_events.create!(event_type: type, occurred_at: Time.current, metadata: {})
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
cancel_timeout_job
|
cancel_timeout_job
|
||||||
|
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
|
||||||
@session.begin_connect! if @session.may_begin_connect?
|
@session.begin_connect! if @session.may_begin_connect?
|
||||||
|
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
|
||||||
log_event("resumed")
|
log_event("resumed")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
|
||||||
|
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "resumed" })
|
||||||
|
Streams::Overlay::Refresh.call(@session)
|
||||||
@session
|
@session
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ module Sessions
|
|||||||
|
|
||||||
def call
|
def call
|
||||||
cancel_timeout_job
|
cancel_timeout_job
|
||||||
|
Streams::OverlayRelay.stop(@session)
|
||||||
|
Streams::YoutubeRelay.stop(@session)
|
||||||
Sessions::RegiaAccess.revoke!(@session)
|
Sessions::RegiaAccess.revoke!(@session)
|
||||||
@session.end_stream!
|
@session.end_stream!
|
||||||
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
||||||
|
|||||||
35
backend/app/services/streams/overlay.rb
Normal file
35
backend/app/services/streams/overlay.rb
Normal file
@@ -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
|
||||||
53
backend/app/services/streams/overlay/badge_label.rb
Normal file
53
backend/app/services/streams/overlay/badge_label.rb
Normal file
@@ -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
|
||||||
48
backend/app/services/streams/overlay/refresh.rb
Normal file
48
backend/app/services/streams/overlay/refresh.rb
Normal file
@@ -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
|
||||||
145
backend/app/services/streams/overlay/svg_builder.rb
Normal file
145
backend/app/services/streams/overlay/svg_builder.rb
Normal file
@@ -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
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="#{CANVAS_W}" height="#{CANVAS_H}" viewBox="0 0 #{CANVAS_W} #{CANVAS_H}">
|
||||||
|
#{scorebug_svg(cols)}
|
||||||
|
#{badge_svg}
|
||||||
|
</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
|
||||||
|
"<text x=\"#{cx}\" y=\"#{y0 + pad + 12}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"10\" font-weight=\"700\" fill=\"#666666\">#{escape(label)}</text>"
|
||||||
|
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 ? "<rect x=\"#{cx - 12}\" y=\"#{y - 13}\" width=\"24\" height=\"16\" rx=\"3\" fill=\"#{home_primary}\" fill-opacity=\"0.14\"/>" : ""
|
||||||
|
"#{bg}<text x=\"#{cx}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"13\" font-weight=\"#{weight}\" fill=\"#{fill}\">#{escape(val)}</text>"
|
||||||
|
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 ? "<rect x=\"#{cx - 12}\" y=\"#{y - 13}\" width=\"24\" height=\"16\" rx=\"3\" fill=\"#{away_primary}\" fill-opacity=\"0.14\"/>" : ""
|
||||||
|
"#{bg}<text x=\"#{cx}\" y=\"#{y}\" text-anchor=\"middle\" font-family=\"DejaVu Sans, sans-serif\" font-size=\"13\" font-weight=\"#{weight}\" fill=\"#{fill}\">#{escape(val)}</text>"
|
||||||
|
end.join
|
||||||
|
|
||||||
|
<<~SVG
|
||||||
|
<g id="scorebug">
|
||||||
|
<rect x="#{x0}" y="#{y0}" width="#{box_w}" height="#{box_h}" rx="6" fill="#ffffff" fill-opacity="0.94"/>
|
||||||
|
<line x1="#{x0 + pad}" y1="#{y0 + pad + head_h}" x2="#{x0 + box_w - pad}" y2="#{y0 + pad + head_h}" stroke="#000000" stroke-opacity="0.12"/>
|
||||||
|
#{header_cells}
|
||||||
|
<text x="#{x0 + pad}" y="#{y0 + pad + head_h + 14}" font-family="DejaVu Sans, sans-serif" font-size="12" font-weight="700" fill="#{home_primary}">#{escape(cols[:home_name])}</text>
|
||||||
|
#{home_pts}
|
||||||
|
<text x="#{x0 + pad}" y="#{y0 + pad + head_h + row_h + 14}" font-family="DejaVu Sans, sans-serif" font-size="12" font-weight="700" fill="#{away_primary}">#{escape(cols[:away_name])}</text>
|
||||||
|
#{away_pts}
|
||||||
|
<rect x="#{x0 + pad}" y="#{y0 + box_h - pad - 18}" width="#{box_w - pad * 2}" height="16" rx="4" fill="url(#brandGrad)"/>
|
||||||
|
<text x="#{x0 + box_w / 2}" y="#{y0 + box_h - pad - 6}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="9" font-weight="800" fill="#ffffff" letter-spacing="1.2">MATCH <tspan fill="#e53935">LIVE TV</tspan></text>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="brandGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#14141c"/>
|
||||||
|
<stop offset="55%" stop-color="#2a1214"/>
|
||||||
|
<stop offset="100%" stop-color="#1a1a24"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
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
|
||||||
|
<g id="badge">
|
||||||
|
<rect x="#{x}" y="#{y}" width="#{tw}" height="#{th}" rx="#{th / 2}" fill="#{bg}"/>
|
||||||
|
<text x="#{x + tw / 2}" y="#{y + th - pad_y - 1}" text-anchor="middle" font-family="DejaVu Sans, sans-serif" font-size="#{fs}" font-weight="700" fill="#{fg}" letter-spacing="0.5">#{escape(text)}</text>
|
||||||
|
</g>
|
||||||
|
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
|
||||||
205
backend/app/services/streams/overlay_relay.rb
Normal file
205
backend/app/services/streams/overlay_relay.rb
Normal file
@@ -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
|
||||||
103
backend/app/services/streams/youtube_relay.rb
Normal file
103
backend/app/services/streams/youtube_relay.rb
Normal file
@@ -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
|
||||||
@@ -25,21 +25,26 @@ module Webhooks
|
|||||||
def handle_connect(session)
|
def handle_connect(session)
|
||||||
return if session.paused?
|
return if session.paused?
|
||||||
|
|
||||||
|
Streams::OverlayRelay.ensure_publishing!(session)
|
||||||
|
|
||||||
return if session.live? && session.stream_events.where(event_type: "connected").exists?
|
return if session.live? && session.stream_events.where(event_type: "connected").exists?
|
||||||
|
|
||||||
if session.reconnecting?
|
if session.reconnecting?
|
||||||
session.reconnect! if session.may_reconnect?
|
session.reconnect! if session.may_reconnect?
|
||||||
|
enable_recording(session)
|
||||||
cancel_timeout(session)
|
cancel_timeout(session)
|
||||||
log(session, "reconnected")
|
log(session, "reconnected")
|
||||||
broadcast(session, "reconnected")
|
broadcast(session, "reconnected")
|
||||||
elsif session.connecting? || session.idle?
|
elsif session.connecting? || session.idle?
|
||||||
session.go_live! if session.may_go_live?
|
session.go_live! if session.may_go_live?
|
||||||
|
enable_recording(session)
|
||||||
log(session, "connected")
|
log(session, "connected")
|
||||||
broadcast(session, "connected")
|
broadcast(session, "connected")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_disconnect(session)
|
def handle_disconnect(session)
|
||||||
|
disable_recording(session)
|
||||||
return if session.paused?
|
return if session.paused?
|
||||||
return unless session.live? || session.connecting?
|
return unless session.live? || session.connecting?
|
||||||
|
|
||||||
@@ -57,6 +62,20 @@ module Webhooks
|
|||||||
log(session, "connected", { ready: true })
|
log(session, "connected", { ready: true })
|
||||||
end
|
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)
|
def schedule_timeout(session)
|
||||||
job = DisconnectionTimeoutJob.perform_in(
|
job = DisconnectionTimeoutJob.perform_in(
|
||||||
MatchLiveTv.reconnect_timeout_seconds.seconds,
|
MatchLiveTv.reconnect_timeout_seconds.seconds,
|
||||||
@@ -92,6 +111,7 @@ module Webhooks
|
|||||||
|
|
||||||
def broadcast(session, event)
|
def broadcast(session, event)
|
||||||
SessionChannel.broadcast_message(session, { type: "stream_event", event: event })
|
SessionChannel.broadcast_message(session, { type: "stream_event", event: event })
|
||||||
|
Streams::Overlay::Refresh.call(session)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=36">
|
<link rel="stylesheet" href="/marketing.css?v=36">
|
||||||
<link rel="stylesheet" href="/live.css?v=4">
|
<link rel="stylesheet" href="/live.css?v=22">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||||
|
|||||||
@@ -13,23 +13,6 @@
|
|||||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
||||||
|
|
||||||
<%= live_match_page_heading(@match) %>
|
<%= live_match_page_heading(@match) %>
|
||||||
<p>
|
|
||||||
<% if @stream_closed %>
|
|
||||||
<span id="status-badge" class="badge badge-ended">Diretta chiusa</span>
|
|
||||||
<% elsif @on_air %>
|
|
||||||
<span id="status-badge" class="badge badge-on-air">In onda</span>
|
|
||||||
<% else %>
|
|
||||||
<span id="status-badge" class="badge badge-wait">In attesa</span>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div id="scoreboard" class="scoreboard">
|
|
||||||
<p id="sets-line" class="sets-line"><%= live_score_sets_label(@session.score_state) || "—" %></p>
|
|
||||||
<p id="partials-line" class="partials-line"<%= " hidden" unless live_score_partials_label(@session.score_state).present? %>>
|
|
||||||
Parziali: <%= live_score_partials_label(@session.score_state) %>
|
|
||||||
</p>
|
|
||||||
<p id="score-line" class="score"><%= live_score_points_label(@match, @session.score_state) %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if @stream_closed %>
|
<% if @stream_closed %>
|
||||||
<div id="stream-ended" class="stream-ended" role="status" aria-live="polite">
|
<div id="stream-ended" class="stream-ended" role="status" aria-live="polite">
|
||||||
@@ -43,76 +26,94 @@
|
|||||||
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
<div class="live-player-wrap">
|
||||||
<video id="player" controls playsinline muted autoplay></video>
|
<video id="player" controls playsinline muted autoplay></video>
|
||||||
<p id="offline-msg" style="display:none;color:#aaa;">La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
|
<%# Tabellone, badge stato e brand sono bruciati nel video (Streams::OverlayRelay). %>
|
||||||
|
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
||||||
|
▶ Avvia la diretta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="offline-msg" class="live-status-msg" hidden>La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
|
||||||
|
<p id="awaiting-msg" class="live-status-msg" hidden>In attesa del segnale dal telefono — apri la camera nell'app e verifica che la diretta sia avviata (non in pausa).</p>
|
||||||
|
<p id="paused-msg" class="live-status-msg" hidden>Trasmissione in pausa — il flusso continua (copertina server).</p>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const sessionId = "<%= @session.id %>";
|
const sessionId = "<%= @session.id %>";
|
||||||
const hlsUrl = "<%= j @session.hls_playback_url %>";
|
const hlsUrl = "<%= j @session.hls_playback_url %>";
|
||||||
const video = document.getElementById("player");
|
const video = document.getElementById("player");
|
||||||
const badge = document.getElementById("status-badge");
|
|
||||||
const setsLine = document.getElementById("sets-line");
|
|
||||||
const partialsLine = document.getElementById("partials-line");
|
|
||||||
const scoreLine = document.getElementById("score-line");
|
|
||||||
const offlineMsg = document.getElementById("offline-msg");
|
const offlineMsg = document.getElementById("offline-msg");
|
||||||
|
const pausedMsg = document.getElementById("paused-msg");
|
||||||
|
const awaitingMsg = document.getElementById("awaiting-msg");
|
||||||
|
const playHint = document.getElementById("play-hint");
|
||||||
|
|
||||||
let hls = null;
|
let hls = null;
|
||||||
|
let playerStarted = false;
|
||||||
let wasOnAir = <%= @on_air ? "true" : "false" %>;
|
let wasOnAir = <%= @on_air ? "true" : "false" %>;
|
||||||
|
let wasPublisherOnline = <%= @publisher_online ? "true" : "false" %>;
|
||||||
|
let wasPaused = <%= @session.paused? ? "true" : "false" %>;
|
||||||
let reloadTimer = null;
|
let reloadTimer = null;
|
||||||
let stallPolls = 0;
|
let liveEdgeTimer = null;
|
||||||
|
let publisherStuckPolls = 0;
|
||||||
|
let didHardReloadForLive = false;
|
||||||
|
let pendingLiveRecovery = false;
|
||||||
|
let liveRecoveryAttempts = 0;
|
||||||
|
const MAX_LIVE_RECOVERY_ATTEMPTS = 10;
|
||||||
let streamClosed = false;
|
let streamClosed = false;
|
||||||
|
const hlsUrlStable = hlsUrl;
|
||||||
|
|
||||||
function setBadge(status, live, onAir, closed) {
|
function hlsConfig() {
|
||||||
if (closed) {
|
return {
|
||||||
badge.textContent = "Diretta chiusa";
|
enableWorker: false,
|
||||||
badge.className = "badge badge-ended";
|
lowLatencyMode: false,
|
||||||
|
liveDurationInfinity: true,
|
||||||
|
liveSyncDurationCount: 3,
|
||||||
|
liveMaxLatencyDurationCount: 8,
|
||||||
|
liveSyncMode: "edge",
|
||||||
|
maxLiveSyncPlaybackRate: 1.08,
|
||||||
|
maxBufferHole: 1.0,
|
||||||
|
backBufferLength: 20,
|
||||||
|
nudgeMaxRetry: 8,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastLiveSeekAt = 0;
|
||||||
|
let firstLevelSynced = false;
|
||||||
|
|
||||||
|
function jumpToLiveEdge(force = false) {
|
||||||
|
if (!hls || !playerStarted) return;
|
||||||
|
const pos = hls.liveSyncPosition;
|
||||||
|
if (pos == null || !Number.isFinite(pos)) {
|
||||||
|
ensurePlaying();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (onAir) {
|
const lag = Math.max(0, pos - video.currentTime);
|
||||||
badge.textContent = "In onda";
|
const now = Date.now();
|
||||||
badge.className = "badge badge-on-air";
|
// Evita seek continui (causano video a scatti): solo se molto indietro o recovery forzato.
|
||||||
} else if (live) {
|
if (!force && lag < 4 && now - lastLiveSeekAt < 8000) {
|
||||||
badge.textContent = "LIVE";
|
ensurePlaying();
|
||||||
badge.className = "badge badge-live";
|
return;
|
||||||
} else if (status === "reconnecting") {
|
|
||||||
badge.textContent = "Riconnessione";
|
|
||||||
badge.className = "badge badge-wait";
|
|
||||||
} else {
|
|
||||||
badge.textContent = status;
|
|
||||||
badge.className = "badge badge-wait";
|
|
||||||
}
|
}
|
||||||
|
if (force || lag > 2) {
|
||||||
|
video.currentTime = Math.max(0, pos - 1);
|
||||||
|
lastLiveSeekAt = now;
|
||||||
|
}
|
||||||
|
ensurePlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSets(score) {
|
function ensurePlaying() {
|
||||||
if (!score) return "—";
|
if (streamClosed || !playerStarted) return;
|
||||||
return `Set ${score.current_set} · Set vinti ${score.home_sets}-${score.away_sets}`;
|
video.play().then(() => {
|
||||||
|
if (playHint) playHint.hidden = true;
|
||||||
|
}).catch(() => {
|
||||||
|
if (playHint) playHint.hidden = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPartials(score) {
|
function liveEdgeLagSec() {
|
||||||
const partials = score?.set_partials;
|
if (!hls || !playerStarted) return 0;
|
||||||
if (!partials?.length) return "";
|
const pos = hls.liveSyncPosition;
|
||||||
return partials
|
if (pos == null || !Number.isFinite(pos)) return 0;
|
||||||
.map((p) => `Set ${p.set} ${p.home}-${p.away}`)
|
return Math.max(0, pos - video.currentTime);
|
||||||
.join(" · ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPoints(data) {
|
|
||||||
if (!data.score || !data.home_name || !data.away_name) return "—";
|
|
||||||
const s = data.score;
|
|
||||||
return `${data.home_name} ${s.home_points} - ${s.away_points} ${data.away_name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateScoreboard(data) {
|
|
||||||
const score = data.score;
|
|
||||||
setsLine.textContent = formatSets(score);
|
|
||||||
const partials = formatPartials(score);
|
|
||||||
if (partials) {
|
|
||||||
partialsLine.textContent = `Parziali: ${partials}`;
|
|
||||||
partialsLine.hidden = false;
|
|
||||||
} else {
|
|
||||||
partialsLine.hidden = true;
|
|
||||||
}
|
|
||||||
scoreLine.textContent = formatPoints(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showStreamEnded() {
|
function showStreamEnded() {
|
||||||
@@ -120,7 +121,7 @@
|
|||||||
streamClosed = true;
|
streamClosed = true;
|
||||||
destroyPlayer();
|
destroyPlayer();
|
||||||
video.style.display = "none";
|
video.style.display = "none";
|
||||||
offlineMsg.style.display = "none";
|
offlineMsg.hidden = true;
|
||||||
const panel = document.createElement("div");
|
const panel = document.createElement("div");
|
||||||
panel.id = "stream-ended";
|
panel.id = "stream-ended";
|
||||||
panel.className = "stream-ended";
|
panel.className = "stream-ended";
|
||||||
@@ -129,18 +130,14 @@
|
|||||||
<div class="stream-ended-icon" aria-hidden="true">📡</div>
|
<div class="stream-ended-icon" aria-hidden="true">📡</div>
|
||||||
<h2>Diretta terminata</h2>
|
<h2>Diretta terminata</h2>
|
||||||
<p>Lo streaming di questa partita è stato chiuso.</p>
|
<p>Lo streaming di questa partita è stato chiuso.</p>
|
||||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
<p class="stream-ended-hint">Ricarica la pagina per l’ultimo stato registrato.</p>
|
||||||
<a href="<%= public_live_index_path %>" class="btn btn-secondary stream-ended-btn">Tutte le dirette</a>
|
<a href="<%= public_live_index_path %>" class="btn btn-secondary stream-ended-btn">Tutte le dirette</a>
|
||||||
`;
|
`;
|
||||||
video.parentNode.insertBefore(panel, video.nextSibling);
|
video.parentNode.insertBefore(panel, video.nextSibling);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hlsUrlFresh() {
|
|
||||||
const sep = hlsUrl.includes("?") ? "&" : "?";
|
|
||||||
return `${hlsUrl}${sep}_ts=${Date.now()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyPlayer() {
|
function destroyPlayer() {
|
||||||
|
playerStarted = false;
|
||||||
if (hls) {
|
if (hls) {
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
hls = null;
|
hls = null;
|
||||||
@@ -149,24 +146,119 @@
|
|||||||
video.load();
|
video.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startPlayer() {
|
/** Segue il live dopo switch copertina→camera (stesso player, stesso URL). */
|
||||||
destroyPlayer();
|
function recoverLiveVideo() {
|
||||||
const url = hlsUrlFresh();
|
if (!hls || !playerStarted) return;
|
||||||
|
try {
|
||||||
|
if (hls.levels?.length) {
|
||||||
|
hls.startLoad(-1);
|
||||||
|
window.setTimeout(() => jumpToLiveEdge(true), 300);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
ensurePlaying();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Hls.isSupported()) {
|
function scheduleLiveEdgeSync() {
|
||||||
hls = new Hls({ lowLatencyMode: true, enableWorker: true });
|
if (liveEdgeTimer) return;
|
||||||
hls.loadSource(url);
|
liveEdgeTimer = setTimeout(() => {
|
||||||
hls.attachMedia(video);
|
liveEdgeTimer = null;
|
||||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
|
recoverLiveVideo();
|
||||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
}, 400);
|
||||||
if (data.fatal) {
|
}
|
||||||
scheduleReload(2500);
|
|
||||||
|
function bindHlsEvents(instance) {
|
||||||
|
instance.on(Hls.Events.MANIFEST_PARSED, () => ensurePlaying());
|
||||||
|
instance.on(Hls.Events.LEVEL_LOADED, () => {
|
||||||
|
if (!firstLevelSynced) {
|
||||||
|
firstLevelSynced = true;
|
||||||
|
jumpToLiveEdge(true);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
instance.on(Hls.Events.ERROR, (_, data) => {
|
||||||
|
if (!data.fatal) return;
|
||||||
|
if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {
|
||||||
|
instance.startLoad(-1);
|
||||||
|
ensurePlaying();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {
|
||||||
|
instance.recoverMediaError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleReload(5000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCaughtUpWithLive() {
|
||||||
|
if (!hls || !playerStarted) return false;
|
||||||
|
const lag = liveEdgeLagSec();
|
||||||
|
return video.readyState >= 2 && lag < 1.2 && (video.videoWidth || 0) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryEscapeCoverBuffer() {
|
||||||
|
if (!playerStarted || streamClosed || liveRecoveryAttempts >= MAX_LIVE_RECOVERY_ATTEMPTS) return;
|
||||||
|
liveRecoveryAttempts += 1;
|
||||||
|
didHardReloadForLive = false;
|
||||||
|
recoverLiveVideo();
|
||||||
|
if (liveRecoveryAttempts >= 2) {
|
||||||
|
hardReloadLiveOnce();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPublisherBack() {
|
||||||
|
pendingLiveRecovery = true;
|
||||||
|
didHardReloadForLive = false;
|
||||||
|
publisherStuckPolls = 0;
|
||||||
|
liveRecoveryAttempts = 0;
|
||||||
|
recoverLiveVideo();
|
||||||
|
window.setTimeout(() => tryEscapeCoverBuffer(), 500);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (!isCaughtUpWithLive()) tryEscapeCoverBuffer();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dopo pausa il buffer HLS resta sulla copertina: forza reload finché il live è allineato. */
|
||||||
|
function onResumeFromPause() {
|
||||||
|
pendingLiveRecovery = true;
|
||||||
|
didHardReloadForLive = false;
|
||||||
|
publisherStuckPolls = 0;
|
||||||
|
liveRecoveryAttempts = 0;
|
||||||
|
tryEscapeCoverBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ultima risorsa: nuova playlist quando il buffer resta sulla copertina con RTMP attivo. */
|
||||||
|
function hardReloadLiveOnce() {
|
||||||
|
if (didHardReloadForLive || !Hls.isSupported()) return;
|
||||||
|
didHardReloadForLive = true;
|
||||||
|
if (hls) {
|
||||||
|
hls.destroy();
|
||||||
|
hls = null;
|
||||||
|
}
|
||||||
|
const url = `${hlsUrlStable}${hlsUrlStable.includes("?") ? "&" : "?"}_live=${Date.now()}`;
|
||||||
|
hls = new Hls(hlsConfig());
|
||||||
|
hls.loadSource(url);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
bindHlsEvents(hls);
|
||||||
|
hls.startLoad(-1);
|
||||||
|
video.play().catch(() => {});
|
||||||
|
playerStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Un solo attach HLS per sessione: MediaMTX concatena slate/camera sullo stesso path. */
|
||||||
|
function startPlayer() {
|
||||||
|
if (playerStarted) return;
|
||||||
|
|
||||||
|
if (Hls.isSupported()) {
|
||||||
|
hls = new Hls(hlsConfig());
|
||||||
|
hls.loadSource(hlsUrlStable);
|
||||||
|
hls.attachMedia(video);
|
||||||
|
bindHlsEvents(hls);
|
||||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||||
video.src = url;
|
video.src = hlsUrlStable;
|
||||||
video.addEventListener("loadedmetadata", () => video.play().catch(() => {}), { once: true });
|
video.addEventListener("loadedmetadata", () => video.play().catch(() => {}), { once: true });
|
||||||
}
|
}
|
||||||
|
playerStarted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReload(delayMs) {
|
function scheduleReload(delayMs) {
|
||||||
@@ -179,11 +271,12 @@
|
|||||||
|
|
||||||
async function pollStatus() {
|
async function pollStatus() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/live/${sessionId}/status.json`);
|
const res = await fetch(`/live/${sessionId}/status.json?t=${Date.now()}`, { cache: "no-store" });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setBadge(data.status, data.live, data.on_air, data.stream_closed);
|
const paused = !!data.paused || data.status === "paused";
|
||||||
updateScoreboard(data);
|
const publisherOnline = !!data.publisher_online;
|
||||||
|
const awaitingSignal = !!data.awaiting_signal;
|
||||||
|
const showingCover = !!data.showing_cover;
|
||||||
if (data.stream_closed) {
|
if (data.stream_closed) {
|
||||||
showStreamEnded();
|
showStreamEnded();
|
||||||
return;
|
return;
|
||||||
@@ -192,39 +285,106 @@
|
|||||||
const onAir = !!data.on_air;
|
const onAir = !!data.on_air;
|
||||||
|
|
||||||
if (onAir) {
|
if (onAir) {
|
||||||
offlineMsg.style.display = "none";
|
offlineMsg.hidden = true;
|
||||||
if (!wasOnAir) {
|
if (!publisherOnline && showingCover && !paused) {
|
||||||
startPlayer();
|
awaitingMsg.hidden = false;
|
||||||
stallPolls = 0;
|
awaitingMsg.textContent = "In attesa del segnale dal telefono — riprendi la diretta dall'app.";
|
||||||
} else if (video.readyState < 2) {
|
|
||||||
stallPolls += 1;
|
|
||||||
if (stallPolls >= 2) {
|
|
||||||
startPlayer();
|
|
||||||
stallPolls = 0;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
stallPolls = 0;
|
awaitingMsg.hidden = !awaitingSignal;
|
||||||
}
|
}
|
||||||
|
pausedMsg.hidden = !paused;
|
||||||
|
if (!playerStarted) startPlayer();
|
||||||
|
|
||||||
|
if (paused) {
|
||||||
|
pendingLiveRecovery = false;
|
||||||
|
liveRecoveryAttempts = 0;
|
||||||
|
} else if (wasPaused) {
|
||||||
|
pendingLiveRecovery = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (publisherOnline && !wasPublisherOnline) {
|
||||||
|
onPublisherBack();
|
||||||
|
} else if (!paused && wasPaused && playerStarted) {
|
||||||
|
onResumeFromPause();
|
||||||
|
} else if (publisherOnline && !paused && playerStarted) {
|
||||||
|
if (pendingLiveRecovery && !isCaughtUpWithLive()) {
|
||||||
|
tryEscapeCoverBuffer();
|
||||||
|
} else if (pendingLiveRecovery && isCaughtUpWithLive()) {
|
||||||
|
pendingLiveRecovery = false;
|
||||||
|
liveRecoveryAttempts = 0;
|
||||||
|
publisherStuckPolls = 0;
|
||||||
} else {
|
} else {
|
||||||
offlineMsg.style.display = "block";
|
const lag = liveEdgeLagSec();
|
||||||
destroyPlayer();
|
if (lag > 4) {
|
||||||
stallPolls = 0;
|
publisherStuckPolls += 1;
|
||||||
|
pendingLiveRecovery = true;
|
||||||
|
if (publisherStuckPolls >= 2) tryEscapeCoverBuffer();
|
||||||
|
else if (publisherStuckPolls >= 1) recoverLiveVideo();
|
||||||
|
} else {
|
||||||
|
publisherStuckPolls = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!publisherOnline) {
|
||||||
|
didHardReloadForLive = false;
|
||||||
|
publisherStuckPolls = 0;
|
||||||
|
}
|
||||||
|
if (video.paused) video.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
offlineMsg.hidden = false;
|
||||||
|
awaitingMsg.hidden = true;
|
||||||
|
pausedMsg.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
wasOnAir = onAir;
|
wasOnAir = onAir;
|
||||||
|
wasPublisherOnline = publisherOnline;
|
||||||
|
wasPaused = paused;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
scheduleReload(5000);
|
scheduleReload(5000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasOnAir) {
|
if (playHint) {
|
||||||
|
playHint.addEventListener("click", () => {
|
||||||
|
jumpToLiveEdge();
|
||||||
|
ensurePlaying();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
video.addEventListener("pause", () => {
|
||||||
|
if (!streamClosed && playerStarted && !video.ended) {
|
||||||
|
if (playHint) playHint.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
video.addEventListener("playing", () => {
|
||||||
|
if (playHint) playHint.hidden = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!streamClosed) {
|
||||||
startPlayer();
|
startPlayer();
|
||||||
|
if (!wasOnAir) offlineMsg.hidden = false;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (playerStarted && video.readyState < 2 && !didHardReloadForLive) {
|
||||||
|
hardReloadLiveOnce();
|
||||||
|
}
|
||||||
|
}, 4500);
|
||||||
} else {
|
} else {
|
||||||
offlineMsg.style.display = "block";
|
offlineMsg.hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
pollStatus();
|
pollStatus();
|
||||||
setInterval(pollStatus, 3000);
|
setInterval(pollStatus, 1500);
|
||||||
|
setInterval(() => {
|
||||||
|
if (!playerStarted || streamClosed || !pendingLiveRecovery) return;
|
||||||
|
if (!isCaughtUpWithLive()) tryEscapeCoverBuffer();
|
||||||
|
}, 6000);
|
||||||
|
|
||||||
|
if (!streamClosed && wasPublisherOnline && !wasPaused) {
|
||||||
|
pendingLiveRecovery = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (playerStarted && !isCaughtUpWithLive()) tryEscapeCoverBuffer();
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<div class="card replay-show__status">
|
<div class="card replay-show__status">
|
||||||
<p>Registrazione in elaborazione. Riceverai un’email quando sarà pronta.</p>
|
<p>Registrazione in elaborazione. Riceverai un’email quando sarà pronta.</p>
|
||||||
</div>
|
</div>
|
||||||
<% elsif @recording.ready? %>
|
<% elsif @recording.ready? && @recording.storage_key.present? %>
|
||||||
<div class="replay-show__player card">
|
<div class="replay-show__player card">
|
||||||
<video id="replay-player" class="replay-video" controls playsinline preload="metadata"
|
<video id="replay-player" class="replay-video" controls playsinline preload="metadata"
|
||||||
<% if @recording.thumbnail_url %> poster="<%= @recording.thumbnail_url %>"<% end %>>
|
<% if @recording.thumbnail_url %> poster="<%= @recording.thumbnail_url %>"<% end %>>
|
||||||
@@ -46,6 +46,11 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<% elsif @recording.ready? %>
|
||||||
|
<div class="card replay-show__status">
|
||||||
|
<p>Il file video non è più disponibile (archivio rimosso o in migrazione).</p>
|
||||||
|
<p class="replay-show__details-line">Durata registrata: <%= @recording.duration_label %> · <%= @recording.byte_size_label %></p>
|
||||||
|
</div>
|
||||||
<% elsif @recording.status == "failed" %>
|
<% elsif @recording.status == "failed" %>
|
||||||
<div class="card replay-show__status">
|
<div class="card replay-show__status">
|
||||||
<p>Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.</p>
|
<p>Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.</p>
|
||||||
|
|||||||
32
backend/bin/overlay_png_feeder.rb
Normal file
32
backend/bin/overlay_png_feeder.rb
Normal file
@@ -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
|
||||||
@@ -41,6 +41,7 @@ Rails.application.routes.draw do
|
|||||||
patch :stop
|
patch :stop
|
||||||
patch :pause
|
patch :pause
|
||||||
patch :resume
|
patch :resume
|
||||||
|
patch :score
|
||||||
get :events
|
get :events
|
||||||
post :telemetry
|
post :telemetry
|
||||||
post :pairing_token
|
post :pairing_token
|
||||||
|
|||||||
18
backend/lib/tasks/stream_overlay.rake
Normal file
18
backend/lib/tasks/stream_overlay.rake
Normal file
@@ -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
|
||||||
BIN
backend/public/images/copertina-canale.png
Normal file
BIN
backend/public/images/copertina-canale.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
@@ -5,11 +5,285 @@
|
|||||||
padding: 24px 20px 48px;
|
padding: 24px 20px 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-main video {
|
.live-main .live-player-wrap {
|
||||||
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-height: 70vh;
|
aspect-ratio: 16 / 9;
|
||||||
background: #000;
|
max-height: min(70vh, calc(100vw * 9 / 16));
|
||||||
|
min-height: 200px;
|
||||||
border-radius: 12px;
|
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 {
|
.live-main .badge {
|
||||||
@@ -24,17 +298,24 @@
|
|||||||
|
|
||||||
.live-main .badge-live { background: #e53935; color: #fff; }
|
.live-main .badge-live { background: #e53935; color: #fff; }
|
||||||
.live-main .badge-on-air { background: #2e7d32; 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-connecting { background: #f57c00; color: #fff; }
|
||||||
.live-main .badge-ended { background: #374151; color: #ddd; }
|
.live-main .badge-ended { background: #374151; color: #ddd; }
|
||||||
|
|
||||||
.live-main .stream-ended {
|
.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;
|
text-align: center;
|
||||||
padding: 48px 24px;
|
padding: 24px;
|
||||||
margin: 16px 0;
|
margin: 0;
|
||||||
background: linear-gradient(160deg, #14141c 0%, #0a0a0e 100%);
|
background: linear-gradient(160deg, #14141c 0%, #0a0a0e 100%);
|
||||||
border: 1px dashed #2a2a36;
|
border: none;
|
||||||
border-radius: 16px;
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.live-main .stream-ended-icon {
|
.live-main .stream-ended-icon {
|
||||||
|
|||||||
33
backend/spec/services/scoring/sync_state_spec.rb
Normal file
33
backend/spec/services/scoring/sync_state_spec.rb
Normal file
@@ -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
|
||||||
102
docs/LIVE_STREAMING.md
Normal file
102
docs/LIVE_STREAMING.md
Normal file
@@ -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).
|
||||||
@@ -17,6 +17,8 @@ Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce
|
|||||||
### 1. Registrazione automatica
|
### 1. Registrazione automatica
|
||||||
Pipeline: `Sessions::Stop` → `FinalizeSession` → `UploadJob` → storage.
|
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
|
### 2. Retention e purge
|
||||||
`Recordings::PurgeExpiredJob` / `rails recordings:purge_expired`
|
`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_ACCESS_KEY_ID` = Key ID Garage (es. `GK...`)
|
||||||
- `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave
|
- `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave
|
||||||
|
|
||||||
## Cron consigliati (produzione)
|
## Cron (produzione)
|
||||||
|
|
||||||
```cron
|
Installazione sul server:
|
||||||
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
|
```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
|
## API mobile
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- recordings:/recordings
|
- recordings:/recordings
|
||||||
|
- stream_overlays:/app/tmp/stream_overlays
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
test: ["CMD", "curl", "-f", "http://127.0.0.1:3000/up"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -150,6 +151,7 @@ services:
|
|||||||
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
||||||
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
||||||
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
||||||
|
OVERLAY_RELAY_PROCESS_CHECK: "false"
|
||||||
depends_on:
|
depends_on:
|
||||||
rails:
|
rails:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -157,6 +159,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- recordings:/recordings
|
- recordings:/recordings
|
||||||
|
- stream_overlays:/app/tmp/stream_overlays
|
||||||
|
|
||||||
garage:
|
garage:
|
||||||
image: dxflrs/garage:v1.0.1
|
image: dxflrs/garage:v1.0.1
|
||||||
@@ -172,5 +175,6 @@ volumes:
|
|||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
recordings:
|
recordings:
|
||||||
|
stream_overlays:
|
||||||
garage_meta:
|
garage_meta:
|
||||||
garage_data:
|
garage_data:
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ rtmpAddress: :1935
|
|||||||
hls: yes
|
hls: yes
|
||||||
hlsAddress: :8888
|
hlsAddress: :8888
|
||||||
hlsVariant: mpegts
|
hlsVariant: mpegts
|
||||||
|
# Allineato a slate (1s) e camera; evita salti 1s→6s che glitchano HLS.js
|
||||||
|
hlsSegmentDuration: 1s
|
||||||
|
hlsSegmentMaxSize: 50M
|
||||||
|
|
||||||
pathDefaults:
|
pathDefaults:
|
||||||
source: publisher
|
source: publisher
|
||||||
overridePublisher: yes
|
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
|
recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S
|
||||||
recordSegmentDuration: 60s
|
recordSegmentDuration: 60s
|
||||||
recordDeleteAfter: 48h
|
recordDeleteAfter: 48h
|
||||||
@@ -37,9 +41,5 @@ paths:
|
|||||||
# match_{uuid}:
|
# match_{uuid}:
|
||||||
# alwaysAvailable: true
|
# alwaysAvailable: true
|
||||||
# alwaysAvailableFile: /slates/offline.mp4
|
# alwaysAvailableFile: /slates/offline.mp4
|
||||||
# alwaysAvailableTracks:
|
# (non usare alwaysAvailableTracks insieme al file — MediaMTX >= 2026.02)
|
||||||
# - codec: H264
|
|
||||||
# - codec: MPEG4Audio
|
|
||||||
# sampleRate: 48000
|
|
||||||
# channelCount: 2
|
|
||||||
all_others:
|
all_others:
|
||||||
|
|||||||
9
infra/scripts/cron/run_rails_task.sh
Executable file
9
infra/scripts/cron/run_rails_task.sh
Executable file
@@ -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 "$@"
|
||||||
37
infra/scripts/garage_set_capacity.sh
Executable file
37
infra/scripts/garage_set_capacity.sh
Executable file
@@ -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
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
#!/bin/sh
|
#!/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
|
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"
|
OUT="$OUT_DIR/offline.mp4"
|
||||||
|
|
||||||
|
if [ ! -f "$BRAND_IMAGE" ]; then
|
||||||
|
echo "Manca immagine brand: $BRAND_IMAGE" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$OUT_DIR"
|
mkdir -p "$OUT_DIR"
|
||||||
docker run --rm -v "$OUT_DIR:/out" linuxserver/ffmpeg:latest \
|
BRAND_DIR="$(cd "$(dirname "$BRAND_IMAGE")" && pwd)"
|
||||||
-f lavfi -i color=c=0x1a1a1a:s=1280x720:r=30 \
|
BRAND_FILE="$(basename "$BRAND_IMAGE")"
|
||||||
-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 \
|
docker run --rm \
|
||||||
-t 30 -c:v libx264 -pix_fmt yuv420p -g 30 -preset fast \
|
-v "$OUT_DIR:/out" \
|
||||||
-c:a aac -b:a 128k -y /out/offline.mp4
|
-v "$BRAND_DIR:/brand:ro" \
|
||||||
echo "Created $OUT"
|
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"
|
||||||
|
|||||||
35
infra/scripts/install_production_cron.sh
Executable file
35
infra/scripts/install_production_cron.sh
Executable file
@@ -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"
|
||||||
@@ -14,6 +14,13 @@ bash "${ROOT}/scripts/ensure_garage_prod_config.sh"
|
|||||||
echo "Avvio Garage..."
|
echo "Avvio Garage..."
|
||||||
docker compose -f docker-compose.prod.yml --env-file .env up -d 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"
|
bash "${ROOT}/scripts/setup_garage_replays.sh"
|
||||||
|
|
||||||
echo "Riavvio Rails e Sidekiq..."
|
echo "Riavvio Rails e Sidekiq..."
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ ensure_layout() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "Node id: $node_id"
|
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
|
gexec layout apply --version 1 2>/dev/null || gexec layout apply 2>/dev/null || true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
# Slate video (offline)
|
# 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.
|
Must match phone encoder settings for seamless `alwaysAvailable` merge.
|
||||||
|
|
||||||
Generate a test slate with ffmpeg:
|
Generate a test slate with ffmpeg:
|
||||||
|
|||||||
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
package com.matchlivetv.match_live_tv
|
package com.matchlivetv.match_live_tv
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
@@ -21,8 +22,9 @@ data class StreamingConfig(
|
|||||||
val audioBitrate: Int = 128_000,
|
val audioBitrate: Int = 128_000,
|
||||||
val fps: Int = 30,
|
val fps: Int = 30,
|
||||||
val rotation: Int = 0,
|
val rotation: Int = 0,
|
||||||
val sampleRate: Int = 44_100,
|
// Allineato a MediaMTX alwaysAvailable slate (offline.mp4: 48 kHz mono)
|
||||||
val stereo: Boolean = true,
|
val sampleRate: Int = 48_000,
|
||||||
|
val stereo: Boolean = false,
|
||||||
val maxReconnectAttempts: Int = 10,
|
val maxReconnectAttempts: Int = 10,
|
||||||
val reconnectDelayMs: Long = 5_000L,
|
val reconnectDelayMs: Long = 5_000L,
|
||||||
)
|
)
|
||||||
@@ -144,11 +146,14 @@ class StreamingEngine(
|
|||||||
stopPreviewAndStreamForPrepare(stream)
|
stopPreviewAndStreamForPrepare(stream)
|
||||||
val prepared = try {
|
val prepared = try {
|
||||||
stream.prepareVideo(
|
stream.prepareVideo(
|
||||||
cfg.width,
|
width = cfg.width,
|
||||||
cfg.height,
|
height = cfg.height,
|
||||||
cfg.videoBitrate,
|
bitrate = cfg.videoBitrate,
|
||||||
cfg.fps,
|
fps = cfg.fps,
|
||||||
|
iFrameInterval = 1,
|
||||||
rotation = cfg.rotation,
|
rotation = cfg.rotation,
|
||||||
|
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||||
|
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
|
||||||
) && stream.prepareAudio(
|
) && stream.prepareAudio(
|
||||||
cfg.sampleRate,
|
cfg.sampleRate,
|
||||||
cfg.stereo,
|
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) {
|
fun startStream(streamConfig: StreamingConfig) {
|
||||||
ensureMainThread()
|
ensureMainThread()
|
||||||
if (isReleased.get()) {
|
if (isReleased.get()) {
|
||||||
@@ -204,11 +248,14 @@ class StreamingEngine(
|
|||||||
|
|
||||||
val prepared = try {
|
val prepared = try {
|
||||||
stream.prepareVideo(
|
stream.prepareVideo(
|
||||||
streamConfig.width,
|
width = streamConfig.width,
|
||||||
streamConfig.height,
|
height = streamConfig.height,
|
||||||
streamConfig.videoBitrate,
|
bitrate = streamConfig.videoBitrate,
|
||||||
streamConfig.fps,
|
fps = streamConfig.fps,
|
||||||
|
iFrameInterval = 1,
|
||||||
rotation = streamConfig.rotation,
|
rotation = streamConfig.rotation,
|
||||||
|
profile = MediaCodecInfo.CodecProfileLevel.AVCProfileBaseline,
|
||||||
|
level = MediaCodecInfo.CodecProfileLevel.AVCLevel31,
|
||||||
) && stream.prepareAudio(
|
) && stream.prepareAudio(
|
||||||
streamConfig.sampleRate,
|
streamConfig.sampleRate,
|
||||||
streamConfig.stereo,
|
streamConfig.stereo,
|
||||||
@@ -347,12 +394,7 @@ class StreamingEngine(
|
|||||||
override fun onNewBitrate(bitrate: Long) {
|
override fun onNewBitrate(bitrate: Long) {
|
||||||
postMain {
|
postMain {
|
||||||
currentBitrateKbps = bitrate / 1000
|
currentBitrateKbps = bitrate / 1000
|
||||||
genericStream?.let { stream ->
|
// Bitrate fissa: l'adattamento on-the-fly causa glitch e GOP instabili su MediaMTX/HLS.
|
||||||
bitrateAdapter.adaptBitrate(
|
|
||||||
bitrate,
|
|
||||||
stream.getStreamClient().hasCongestion(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
emitMetrics()
|
emitMetrics()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,8 +512,8 @@ class StreamingEngine(
|
|||||||
|
|
||||||
val elapsedMs = now - lastFpsSampleAtMs
|
val elapsedMs = now - lastFpsSampleAtMs
|
||||||
if (elapsedMs >= 1_000L) {
|
if (elapsedMs >= 1_000L) {
|
||||||
val frameDelta = sentFrames - lastVideoFrameCount
|
val frameDelta = (sentFrames - lastVideoFrameCount).coerceAtLeast(0L)
|
||||||
currentFps = ((frameDelta * 1000L) / elapsedMs).toInt()
|
currentFps = ((frameDelta * 1000L) / elapsedMs).toInt().coerceIn(0, 120)
|
||||||
lastVideoFrameCount = sentFrames
|
lastVideoFrameCount = sentFrames
|
||||||
lastFpsSampleAtMs = now
|
lastFpsSampleAtMs = now
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class StreamingPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, EventCh
|
|||||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"startStream" -> startStream(call, result)
|
"startStream" -> startStream(call, result)
|
||||||
|
"resumeStream" -> resumeStream(call, result)
|
||||||
"stopStream" -> stopStream(result)
|
"stopStream" -> stopStream(result)
|
||||||
"pauseStream" -> pauseStream(result)
|
"pauseStream" -> pauseStream(result)
|
||||||
"getMetrics" -> getMetrics(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) {
|
private fun stopStream(result: Result) {
|
||||||
appContext.startService(StreamingForegroundService.stopIntent(appContext))
|
appContext.startService(StreamingForegroundService.stopIntent(appContext))
|
||||||
getEngine()?.let { engine ->
|
getEngine()?.let { engine ->
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import '../../platform/streaming_channel.dart';
|
|||||||
import '../../platform/streaming_preview.dart';
|
import '../../platform/streaming_preview.dart';
|
||||||
import '../../providers/match_rules_provider.dart';
|
import '../../providers/match_rules_provider.dart';
|
||||||
import '../../providers/score_provider.dart';
|
import '../../providers/score_provider.dart';
|
||||||
|
import '../../providers/score_sync_provider.dart';
|
||||||
import '../../shared/api_client.dart';
|
import '../../shared/api_client.dart';
|
||||||
import '../../shared/regia_share.dart';
|
import '../../shared/regia_share.dart';
|
||||||
import '../../shared/watch_share.dart';
|
import '../../shared/watch_share.dart';
|
||||||
@@ -42,6 +43,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
Timer? _elapsedTimer;
|
Timer? _elapsedTimer;
|
||||||
Timer? _telemetryTimer;
|
Timer? _telemetryTimer;
|
||||||
Timer? _statsTimer;
|
Timer? _statsTimer;
|
||||||
|
Timer? _scorePullTimer;
|
||||||
int _batteryLevel = 100;
|
int _batteryLevel = 100;
|
||||||
int? _lastDelta;
|
int? _lastDelta;
|
||||||
int _prevHomePoints = 0;
|
int _prevHomePoints = 0;
|
||||||
@@ -55,6 +57,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
StreamSubscription<Map<String, dynamic>>? _metricsSub;
|
StreamSubscription<Map<String, dynamic>>? _metricsSub;
|
||||||
Orientation? _lastOrientation;
|
Orientation? _lastOrientation;
|
||||||
bool _pauseInFlight = false;
|
bool _pauseInFlight = false;
|
||||||
|
bool _resumeInFlight = false;
|
||||||
|
String? _lastStreamErrorShown;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -73,6 +77,9 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
});
|
});
|
||||||
_telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry());
|
_telemetryTimer = Timer.periodic(const Duration(seconds: 10), (_) => _sendTelemetry());
|
||||||
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
|
_statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats());
|
||||||
|
_scorePullTimer = Timer.periodic(const Duration(seconds: 2), (_) {
|
||||||
|
unawaited(ref.read(scoreSyncProvider).pullFromServer());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -121,15 +128,18 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final match = await client.fetchMatch(session.matchId);
|
final match = await client.fetchMatch(session.matchId);
|
||||||
|
final statusAtOpen = session.status;
|
||||||
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
||||||
if (session.score != null) {
|
if (session.score != null) {
|
||||||
ref.read(scoreProvider.notifier).reset(session.score!);
|
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') {
|
if (session.status == 'idle') {
|
||||||
session = await client.startSession(widget.sessionId);
|
session = await client.startSession(widget.sessionId);
|
||||||
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
||||||
|
justStartedSession = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
final ws = ref.read(websocketServiceProvider);
|
final ws = ref.read(websocketServiceProvider);
|
||||||
@@ -140,18 +150,41 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
|
|
||||||
final fresh = await client.fetchSession(widget.sessionId);
|
final fresh = await client.fetchSession(widget.sessionId);
|
||||||
ref.read(sessionProvider.notifier).setSession(fresh, match: match);
|
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<void>.delayed(const Duration(milliseconds: 500));
|
await Future<void>.delayed(const Duration(milliseconds: 500));
|
||||||
|
final useFreshStart =
|
||||||
|
justStartedSession || fresh.status == 'connecting';
|
||||||
|
if (useFreshStart) {
|
||||||
await StreamingChannel.startStream(
|
await StreamingChannel.startStream(
|
||||||
rtmpUrl: fresh.rtmpIngestUrl!,
|
rtmpUrl: fresh.rtmpIngestUrl!,
|
||||||
targetBitrate: fresh.targetBitrate,
|
targetBitrate: fresh.targetBitrate,
|
||||||
targetFps: 30,
|
targetFps: 30,
|
||||||
);
|
);
|
||||||
if (mounted && fresh.canResumeBroadcast && fresh.status != 'paused') {
|
} else {
|
||||||
|
await StreamingChannel.resumeStream(
|
||||||
|
rtmpUrl: fresh.rtmpIngestUrl!,
|
||||||
|
targetBitrate: fresh.targetBitrate,
|
||||||
|
targetFps: 30,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (mounted && (justStartedSession || fresh.status == 'connecting')) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Diretta ripresa — stai di nuovo in onda'),
|
content: Text('Diretta avviata — connessione al server…'),
|
||||||
duration: Duration(seconds: 3),
|
duration: Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -166,10 +199,25 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
final metrics = Map<String, dynamic>.from(
|
final metrics = Map<String, dynamic>.from(
|
||||||
(event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {},
|
(event['metrics'] as Map?)?.map((k, v) => MapEntry(k.toString(), v)) ?? {},
|
||||||
);
|
);
|
||||||
|
final streamState = metrics['state'] as String?;
|
||||||
|
final lastError = metrics['lastError'] as String?;
|
||||||
setState(() {
|
setState(() {
|
||||||
_bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000;
|
_bitrateMbps = (metrics['bitrateKbps'] as num? ?? 0) / 1000;
|
||||||
_fps = (metrics['fps'] as num?)?.toInt() ?? 0;
|
_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) {
|
} on PlatformException catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -339,7 +387,39 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _resumeStream() async {
|
Future<void> _resumeStream() async {
|
||||||
|
if (_resumeInFlight) return;
|
||||||
|
_resumeInFlight = true;
|
||||||
try {
|
try {
|
||||||
final match = ref.read(sessionProvider).match;
|
final match = ref.read(sessionProvider).match;
|
||||||
var session =
|
var session =
|
||||||
@@ -347,7 +427,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
ref.read(sessionProvider.notifier).setSession(session, match: match);
|
||||||
|
|
||||||
if (session.rtmpIngestUrl != null) {
|
if (session.rtmpIngestUrl != null) {
|
||||||
await StreamingChannel.startStream(
|
await StreamingChannel.resumeStream(
|
||||||
rtmpUrl: session.rtmpIngestUrl!,
|
rtmpUrl: session.rtmpIngestUrl!,
|
||||||
targetBitrate: session.targetBitrate,
|
targetBitrate: session.targetBitrate,
|
||||||
targetFps: 30,
|
targetFps: 30,
|
||||||
@@ -371,6 +451,8 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
SnackBar(content: Text('Ripresa diretta: $e')),
|
SnackBar(content: Text('Ripresa diretta: $e')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
_resumeInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +495,7 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
_elapsedTimer?.cancel();
|
_elapsedTimer?.cancel();
|
||||||
_telemetryTimer?.cancel();
|
_telemetryTimer?.cancel();
|
||||||
_statsTimer?.cancel();
|
_statsTimer?.cancel();
|
||||||
|
_scorePullTimer?.cancel();
|
||||||
_metricsSub?.cancel();
|
_metricsSub?.cancel();
|
||||||
WakelockPlus.disable();
|
WakelockPlus.disable();
|
||||||
_unlockOrientation();
|
_unlockOrientation();
|
||||||
@@ -427,6 +510,11 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
if (next == 'paused' && previous != 'paused' && !_pauseInFlight) {
|
if (next == 'paused' && previous != 'paused' && !_pauseInFlight) {
|
||||||
_applyRemotePause();
|
_applyRemotePause();
|
||||||
}
|
}
|
||||||
|
if (previous == 'paused' &&
|
||||||
|
(next == 'connecting' || next == 'live') &&
|
||||||
|
!_resumeInFlight) {
|
||||||
|
_applyRemoteResume();
|
||||||
|
}
|
||||||
if (next == 'ended' && previous != 'ended' && mounted) {
|
if (next == 'ended' && previous != 'ended' && mounted) {
|
||||||
_leaveSession();
|
_leaveSession();
|
||||||
}
|
}
|
||||||
@@ -459,6 +547,26 @@ class _CameraScreenState extends ConsumerState<CameraScreen> {
|
|||||||
platformLabel: isPaused ? 'PAUSA' : 'LIVE',
|
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)
|
if (isLandscape)
|
||||||
_LandscapeOverlay(
|
_LandscapeOverlay(
|
||||||
elapsed: sessionState.elapsed,
|
elapsed: sessionState.elapsed,
|
||||||
|
|||||||
@@ -35,6 +35,19 @@ class StreamingChannel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ripresa dopo pausa: resetta stati RTMP residui (CONNECTING/RECONNECTING).
|
||||||
|
static Future<void> resumeStream({
|
||||||
|
required String rtmpUrl,
|
||||||
|
int targetBitrate = 2500000,
|
||||||
|
int targetFps = 30,
|
||||||
|
}) async {
|
||||||
|
await _channel.invokeMethod('resumeStream', {
|
||||||
|
'rtmpUrl': rtmpUrl,
|
||||||
|
'videoBitrate': targetBitrate,
|
||||||
|
'fps': targetFps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> stopStream() async {
|
static Future<void> stopStream() async {
|
||||||
await _channel.invokeMethod('stopStream');
|
await _channel.invokeMethod('stopStream');
|
||||||
}
|
}
|
||||||
|
|||||||
77
mobile/lib/providers/score_sync_provider.dart
Normal file
77
mobile/lib/providers/score_sync_provider.dart
Normal file
@@ -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<void> 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<void> 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<ScoreSyncService>((ref) {
|
||||||
|
return ScoreSyncService(ref);
|
||||||
|
});
|
||||||
@@ -73,11 +73,17 @@ class SessionNotifier extends StateNotifier<SessionState> {
|
|||||||
}
|
}
|
||||||
if (device.status != null && state.session != null) {
|
if (device.status != null && state.session != null) {
|
||||||
final s = state.session!;
|
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(
|
state = state.copyWith(
|
||||||
session: StreamSession(
|
session: StreamSession(
|
||||||
id: s.id,
|
id: s.id,
|
||||||
matchId: s.matchId,
|
matchId: s.matchId,
|
||||||
status: device.status!,
|
status: incoming,
|
||||||
platform: s.platform,
|
platform: s.platform,
|
||||||
rtmpIngestUrl: s.rtmpIngestUrl,
|
rtmpIngestUrl: s.rtmpIngestUrl,
|
||||||
hlsPlaybackUrl: s.hlsPlaybackUrl,
|
hlsPlaybackUrl: s.hlsPlaybackUrl,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../providers/auth_provider.dart';
|
|||||||
import 'models/invite_preview.dart';
|
import 'models/invite_preview.dart';
|
||||||
import 'models/match.dart';
|
import 'models/match.dart';
|
||||||
import 'models/recording_item.dart';
|
import 'models/recording_item.dart';
|
||||||
|
import 'models/score_state.dart';
|
||||||
import 'models/stream_session.dart';
|
import 'models/stream_session.dart';
|
||||||
import 'models/team.dart';
|
import 'models/team.dart';
|
||||||
import 'models/user.dart';
|
import 'models/user.dart';
|
||||||
@@ -210,6 +211,19 @@ class ApiClient {
|
|||||||
return StreamSession.fromJson(response.data!);
|
return StreamSession.fromJson(response.data!);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persiste il tabellone e restituisce lo stato salvato sul server.
|
||||||
|
Future<ScoreState?> syncScore(String sessionId, Map<String, dynamic> payload) async {
|
||||||
|
final response = await _dio.patch<Map<String, dynamic>>(
|
||||||
|
'/sessions/$sessionId/score',
|
||||||
|
data: payload,
|
||||||
|
);
|
||||||
|
final score = response.data?['score'];
|
||||||
|
if (score is Map<String, dynamic>) {
|
||||||
|
return ScoreState.fromJson(score);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<RegiaLinkResponse> createRegiaLink(String sessionId) async {
|
Future<RegiaLinkResponse> createRegiaLink(String sessionId) async {
|
||||||
final response = await _dio.post<Map<String, dynamic>>(
|
final response = await _dio.post<Map<String, dynamic>>(
|
||||||
'/sessions/$sessionId/regia_link',
|
'/sessions/$sessionId/regia_link',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:x_action_cable_v2/x_action_cable.dart';
|
|||||||
import '../core/config.dart';
|
import '../core/config.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/score_provider.dart';
|
import '../providers/score_provider.dart';
|
||||||
|
import '../providers/score_sync_provider.dart';
|
||||||
import '../providers/session_provider.dart';
|
import '../providers/session_provider.dart';
|
||||||
import 'models/device_state.dart';
|
import 'models/device_state.dart';
|
||||||
import 'models/score_state.dart';
|
import 'models/score_state.dart';
|
||||||
@@ -88,7 +89,10 @@ class WebsocketService {
|
|||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'score_update':
|
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;
|
break;
|
||||||
case 'device_state':
|
case 'device_state':
|
||||||
_ref
|
_ref
|
||||||
@@ -109,6 +113,14 @@ class WebsocketService {
|
|||||||
_ref.read(sessionProvider.notifier).applyCommand(action);
|
_ref.read(sessionProvider.notifier).applyCommand(action);
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../app/theme.dart';
|
import '../../app/theme.dart';
|
||||||
import '../../features/shared/live_score_actions.dart';
|
import '../../features/shared/live_score_actions.dart';
|
||||||
import '../../providers/score_provider.dart';
|
import '../../providers/score_provider.dart';
|
||||||
import '../websocket_service.dart';
|
import '../../providers/score_sync_provider.dart';
|
||||||
import 'score_overlay_bar.dart';
|
import 'score_overlay_bar.dart';
|
||||||
|
|
||||||
/// Tabellone interattivo: aggiorna punteggio via WebSocket (senza link regia).
|
/// Tabellone interattivo: aggiorna punteggio via WebSocket (senza link regia).
|
||||||
@@ -27,8 +29,8 @@ class LiveScoreControls extends ConsumerWidget {
|
|||||||
final Future<void> Function()? onStopStream;
|
final Future<void> Function()? onStopStream;
|
||||||
|
|
||||||
void _sync(WidgetRef ref) {
|
void _sync(WidgetRef ref) {
|
||||||
if (!ref.read(websocketServiceProvider).isConnected) return;
|
// HTTP serializzato; il server fa broadcast WebSocket. Pagina live = status.json.
|
||||||
ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider));
|
unawaited(ref.read(scoreSyncProvider).push());
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pointHome(BuildContext context, WidgetRef ref) async {
|
Future<void> _pointHome(BuildContext context, WidgetRef ref) async {
|
||||||
|
|||||||
103
scripts/test/adb_phone_stream_e2e.sh
Executable file
103
scripts/test/adb_phone_stream_e2e.sh
Executable file
@@ -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."
|
||||||
95
scripts/test/live_e2e_cycle.sh
Executable file
95
scripts/test/live_e2e_cycle.sh
Executable file
@@ -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"
|
||||||
40
scripts/test/rtmp_test_pattern.sh
Executable file
40
scripts/test/rtmp_test_pattern.sh
Executable file
@@ -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[@]}"
|
||||||
Reference in New Issue
Block a user