Aggiunge copertina sponsor custom solo Premium Full, con override in wizard e slate sui nodi di streaming.

Permette upload da portale e app con ereditarietà partita→squadra→società, generazione MP4 via ffmpeg e distribuzione su home lab e nodi CPX per pause e assenza segnale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 23:47:17 +02:00
co-authored by Cursor
parent a51d5e8da5
commit 41d4235258
62 changed files with 1487 additions and 20 deletions
+9 -3
View File
@@ -28,7 +28,7 @@ module Mediamtx
overridePublisher: true
)
body[:alwaysAvailable] = true
body[:alwaysAvailableFile] = slate_file_path
body[:alwaysAvailableFile] = slate_file_path(session)
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
unless response.success?
@@ -81,7 +81,7 @@ module Mediamtx
return true if always_available_remembered?(path, enabled: enabled)
body = if enabled
{ alwaysAvailable: true, alwaysAvailableFile: slate_file_path }
{ alwaysAvailable: true, alwaysAvailableFile: slate_file_path(session) }
else
{ alwaysAvailable: false }
end
@@ -173,7 +173,13 @@ module Mediamtx
SCRIPT
end
def slate_file_path
def slate_file_path(session)
return default_slate_file_path if session.blank?
Streams::SlateDistributor.slate_path_for_session(session)
end
def default_slate_file_path
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
end
+2
View File
@@ -37,6 +37,8 @@ module Sessions
session.stream_node = Streams::NodeRegistry.allocate!
session.save!
Scoring::Engine.ensure_score_for(session)
Streams::CoverSlateEnsurer.ensure_for!(@match)
Streams::SlateDistributor.ensure_for!(session)
Mediamtx::Client.for_session(session).create_path(session)
log_event(
session,
@@ -0,0 +1,66 @@
module Streams
# Catena effective cover: partita → squadra → società → default Match Live TV.
class CoverResolver
Result = Struct.new(:record, :source, :cover_url, :slate_local_path, keyword_init: true)
DEFAULT_COVER_URL = "/images/copertina-canale.png".freeze
def initialize(match)
@match = match
@team = match.team
@club = @team.club
@entitlements = @team.entitlements
end
def effective_cover_url
resolve&.cover_url || default_cover_url
end
def cover_source
resolve&.source || "default"
end
def resolve
return nil unless @entitlements.can_use_custom_cover?
candidates.each do |record, source|
next unless record.cover_image.attached?
next unless record.cover_slate.attached?
path = CoverSlatePaths.local_path_for(record)
next if path.blank?
return Result.new(
record: record,
source: source,
cover_url: blob_path(record.cover_image),
slate_local_path: path
)
end
nil
end
def default_cover_url
DEFAULT_COVER_URL
end
def default_slate_path
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
end
private
def candidates
[
[@match, "match"],
[@team, "team"],
[@club, "club"]
]
end
def blob_path(attachment)
Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)
end
end
end
@@ -0,0 +1,35 @@
module Streams
# Garantisce che la slate MP4 esista per il record effettivo della partita (sync al go-live).
class CoverSlateEnsurer
def self.ensure_for!(match)
new(match).ensure!
end
def initialize(match)
@match = match
@resolver = CoverResolver.new(match)
end
def ensure!
result = @resolver.resolve
return unless result
record = result.record
return if slate_ready?(record)
GenerateCoverSlate.call(record)
rescue GenerateCoverSlate::Error => e
Rails.logger.warn("[CoverSlateEnsurer] match=#{@match.id} #{e.message}")
nil
end
private
def slate_ready?(record)
return false unless record.cover_slate.attached?
path = CoverSlatePaths.local_path_for(record)
path.present? && File.exist?(path)
end
end
end
@@ -0,0 +1,30 @@
module Streams
class CoverSlatePaths
def self.slates_custom_root
ENV.fetch("MEDIAMTX_SLATES_CUSTOM_DIR", "/slates/custom")
end
def self.filename_for(record)
return nil unless record.cover_slate.attached?
digest = record.cover_slate.blob.checksum
prefix = record.class.name.underscore
"#{prefix}-#{record.id}-#{digest}.mp4"
end
def self.expected_path(record)
name = filename_for(record)
return nil if name.blank?
File.join(slates_custom_root, name)
end
def self.local_path_for(record)
path = expected_path(record)
return nil if path.blank?
return path if File.exist?(path)
nil
end
end
end
@@ -0,0 +1,121 @@
require "open3"
module Streams
# Converte cover_image in MP4 slate (H.264 720p + AAC mono), allineato a infra/scripts/generate_slate.sh.
class GenerateCoverSlate
class Error < StandardError; end
SLATE_DURATION_SECS = 30
VIDEO_FILTER = "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x0a0a0e".freeze
def self.call(record)
new(record).call
end
def initialize(record)
@record = record
end
def call
return @record unless @record.cover_image.attached?
@image_temp = download_cover_image
@mp4_temp = encode_slate(@image_temp)
attach_slate(@mp4_temp)
write_to_slates_dir(@mp4_temp)
@record
rescue Error
raise
rescue StandardError => e
raise Error, e.message
ensure
cleanup_tempfiles
end
private
def download_cover_image
ext = extension_for(@record.cover_image.content_type)
path = temp_path("cover-in", ext)
@record.cover_image.blob.open(tmpdir: Dir.tmpdir) do |file|
FileUtils.cp(file.path, path)
end
path
end
def encode_slate(image_path)
out = temp_path("cover-slate", ".mp4")
cmd = ffmpeg_args(image_path, out)
stdout, stderr, status = Open3.capture3(*cmd)
unless status.success? && File.exist?(out) && File.size(out).positive?
detail = stderr.to_s.strip.presence || stdout.to_s.strip
raise Error, "ffmpeg slate failed: #{detail}"
end
out
end
def ffmpeg_args(input, output)
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
"-loop", "1", "-framerate", "30", "-i", input,
"-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono",
"-filter:v", VIDEO_FILTER,
"-map", "0:v", "-map", "1:a",
"-t", SLATE_DURATION_SECS.to_s,
"-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", output
]
end
def attach_slate(mp4_path)
@record.cover_slate.purge if @record.cover_slate.attached?
File.open(mp4_path, "rb") do |io|
@record.cover_slate.attach(
io: io,
filename: slate_filename,
content_type: "video/mp4"
)
end
@record.reload
end
def write_to_slates_dir(mp4_path)
dest = CoverSlatePaths.expected_path(@record)
return unless dest
FileUtils.mkdir_p(File.dirname(dest))
FileUtils.cp(mp4_path, dest)
rescue Errno::EACCES, Errno::EROFS => e
Rails.logger.info("[GenerateCoverSlate] skip disk copy #{dest}: #{e.class}")
end
def slate_filename
"#{@record.class.name.underscore}-#{@record.id}.mp4"
end
def extension_for(content_type)
case content_type
when "image/png" then ".png"
when "image/webp" then ".webp"
else ".jpg"
end
end
def temp_path(prefix, ext)
path = File.join(Dir.tmpdir, "#{prefix}-#{@record.class.name}-#{@record.id}-#{SecureRandom.hex(4)}#{ext}")
@tempfiles ||= []
@tempfiles << path
path
end
def cleanup_tempfiles
Array(@tempfiles).each do |path|
FileUtils.rm_f(path)
end
end
end
end
@@ -0,0 +1,101 @@
module Streams
# Assicura che la slate MP4 custom sia sul nodo assegnato (home/lab locale, CPX via agent).
class SlateDistributor
class Error < StandardError; end
def self.slate_path_for_session(session)
new(session).mediamtx_slate_path
end
def self.ensure_for!(session)
new(session).ensure!
end
def initialize(session)
@session = session
@match = session.match
@node = session.stream_node
@resolver = CoverResolver.new(@match)
end
def mediamtx_slate_path
ensure! unless defined?(@mediamtx_path)
@mediamtx_path
end
def ensure!
result = @resolver.resolve
unless result
@mediamtx_path = default_slate_path
return @mediamtx_path
end
host_path = result.slate_local_path
filename = File.basename(host_path)
mediamtx_path = "#{CoverSlatePaths.slates_custom_root}/#{filename}"
if cloud_node?
push_to_agent!(filename, result.record, host_path)
else
verify_local_slate!(host_path)
end
@mediamtx_path = mediamtx_path
rescue Error => e
Rails.logger.warn("[SlateDistributor] session=#{@session.id} #{e.message}")
@mediamtx_path = default_slate_path
end
private
def cloud_node?
@node&.role == "cloud" && agent_url.present?
end
def agent_url
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || @node&.relay_agent_url
end
def agent_secret
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end
def push_to_agent!(filename, record, host_path)
bytes = read_slate_bytes(record, host_path)
raise Error, "slate bytes missing for #{filename}" if bytes.blank?
uri = URI.parse("#{agent_url.chomp('/')}/slates/#{CGI.escape(filename)}")
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = 120
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
req["Content-Type"] = "video/mp4"
req.body = bytes
res = http.request(req)
return if res.is_a?(Net::HTTPSuccess)
raise Error, "agent PUT /slates/#{filename} HTTP #{res.code} #{res.body.to_s.truncate(200)}"
end
def read_slate_bytes(record, host_path)
return File.binread(host_path) if host_path.present? && File.exist?(host_path)
return unless record.cover_slate.attached?
record.cover_slate.blob.open(tmpdir: Dir.tmpdir) do |file|
return File.binread(file.path)
end
end
def verify_local_slate!(host_path)
return if host_path.present? && File.exist?(host_path)
raise Error, "slate missing on disk: #{host_path}"
end
def default_slate_path
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
end
end
end
+16 -1
View File
@@ -35,6 +35,20 @@ module Teams
active? && plan.phone_download_enabled?
end
def can_use_custom_cover?
premium_full? && plan.custom_cover_enabled?
end
def assert_can_upload_cover!
unless can_use_custom_cover?
raise EntitlementError.new(
"La copertina sponsor personalizzata richiede Premium Full",
code: "premium_full_required",
billing_url: billing_url
)
end
end
def max_staff_transmission
plan.max_staff_transmission
end
@@ -225,7 +239,8 @@ module Teams
recording_retention_days: recording_retention_days,
phone_download_enabled: phone_download_enabled?,
youtube_enabled: youtube_enabled?,
youtube_mode: plan.youtube_mode
youtube_mode: plan.youtube_mode,
custom_cover_enabled: can_use_custom_cover?
}
end