Compare commits

..
Author SHA1 Message Date
eminuxandCursor 05ef56c56d Aggiunge replay YouTube temporaneo, pull registrazioni dai CPX e snapshot ingest in admin.
Così overflow Hetzner e VOD YouTube restano in archivio dopo lo spegnimento del nodo, e la colonna ingest non si svuota.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:01:41 +02:00
eminuxandCursor 35dfa923e3 Usa packageName dinamico negli E2E Android per la variante collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:22:20 +02:00
eminuxandCursor 9d8b35c06c Salva telemetria client (OS, app, device, operatore) sulle sessioni per il debug admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 09:43:43 +02:00
eminuxandCursor 873e0ea55c Fix race analytics: retry su unique violation in aggregazione heatmap.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:23:02 +02:00
eminuxandCursor 4573edfedc Aggiunge i link ai profili social ufficiali nel footer e in Contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:10:18 +02:00
eminuxandCursor b301868774 Aggiunge regola Cursor per scalare i soft limit ingest col crescere dei clienti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 15:11:25 +02:00
eminuxandCursor b87a0f7bc0 Fix race create sessione su CPX: ready solo dopo MediaMTX.
I nodi cloud restano in provisioning finché :9997 risponde; retry su create_path e 503 retryable se l’ingest è ancora irraggiungibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 14:06:47 +02:00
98 changed files with 2863 additions and 247 deletions
@@ -0,0 +1,22 @@
---
description: Scalare soft limit ingest/autoscaler man mano che crescono i clienti
alwaysApply: true
---
# Soft limit stream / autoscaler
Con la crescita del numero di clienti (e delle dirette concorrenti attese), **alzare i soft limit** prima di restare a corto di capacità.
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
| Parametro | Ruolo oggi (post load test 2026-08) |
|-----------|--------------------------------------|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) |
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) |
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) |
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (es. 6+3×4 = **18**).
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo.
@@ -79,14 +79,21 @@ module Admin
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status]) if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
scope = scope.where(stream_sessions: { status: @filters[:status] }) scope = scope.where(stream_sessions: { status: @filters[:status] })
end end
if @filters[:platform].present? && StreamSession::PLATFORMS.include?(@filters[:platform]) if @filters[:platform].present? && StreamSession::LIVE_PLATFORMS.include?(@filters[:platform])
scope = scope.where(stream_sessions: { platform: @filters[:platform] }) scope = scope.where(stream_sessions: { platform: @filters[:platform] })
end end
if @filters[:club_id].present? if @filters[:club_id].present?
scope = scope.where(teams: { club_id: @filters[:club_id] }) scope = scope.where(teams: { club_id: @filters[:club_id] })
end end
if @filters[:stream_node_id].present? if @filters[:stream_node_id].present?
scope = scope.where(stream_sessions: { stream_node_id: @filters[:stream_node_id] }) node = StreamNode.find_by(id: @filters[:stream_node_id])
if node
scope = scope.where(
"stream_sessions.stream_node_id = :id OR stream_sessions.ingest_slug = :slug",
id: node.id,
slug: node.slug
)
end
end end
if (from_time = parse_filter_date(@filters[:from], end_of_day: false)) if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
scope = scope.where( scope = scope.where(
@@ -2,6 +2,7 @@ module Api
module V1 module V1
class BaseController < ApplicationController class BaseController < ApplicationController
rescue_from Teams::EntitlementError, with: :render_entitlement_error rescue_from Teams::EntitlementError, with: :render_entitlement_error
rescue_from Streams::IngestUnavailableError, with: :render_ingest_unavailable
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
@@ -22,6 +23,13 @@ module Api
}, status: :forbidden }, status: :forbidden
end end
def render_ingest_unavailable(error)
render json: {
error: error.message,
error_code: error.code
}, status: :service_unavailable
end
def render_cover_upload_error(error) def render_cover_upload_error(error)
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
end end
@@ -97,10 +97,11 @@ module Api
replay_url: recording.replay_url, replay_url: recording.replay_url,
playback_url: recording.playback_stream_url, playback_url: recording.playback_stream_url,
thumbnail_url: recording.thumbnail_url, thumbnail_url: recording.thumbnail_url,
download_enabled: ent.phone_download_enabled?, download_enabled: ent.phone_download_enabled? && recording.storage_key.present?,
youtube_video_id: recording.youtube_video_id, youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url, youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled? && recording.storage_key.present? && recording.youtube_video_id.blank?,
replay_source: recording.replay_source,
source_platform: recording.source_platform, source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label, source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at, expires_at: recording.expires_at,
@@ -77,6 +77,7 @@ module Api
thermal_state: sanitized_thermal_state, thermal_state: sanitized_thermal_state,
last_seen_at: Time.current last_seen_at: Time.current
) )
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
sync_publisher_when_streaming!(params[:fps].to_f) sync_publisher_when_streaming!(params[:fps].to_f)
SessionChannel.broadcast_message(@session, state.as_cable_payload) SessionChannel.broadcast_message(@session, state.as_cable_payload)
head :no_content head :no_content
@@ -180,7 +181,12 @@ module Api
end end
def session_params def session_params
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,
client: %i[os client_os app_version app_build version build build_number
device_manufacturer manufacturer device_model model
os_version system_version carrier network_operator operator]
)
end end
def score_sync_params def score_sync_params
@@ -171,12 +171,13 @@ module Api
replay_url: recording.replay_url, replay_url: recording.replay_url,
playback_url: recording.playback_stream_url, playback_url: recording.playback_stream_url,
thumbnail_url: recording.thumbnail_url, thumbnail_url: recording.thumbnail_url,
download_enabled: ent.phone_download_enabled?, download_enabled: ent.phone_download_enabled? && recording.storage_key.present?,
view_count: recording.view_count, view_count: recording.view_count,
views_label: recording.views_label, views_label: recording.views_label,
youtube_video_id: recording.youtube_video_id, youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url, youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?, youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled? && recording.storage_key.present? && recording.youtube_video_id.blank?,
replay_source: recording.replay_source,
source_platform: recording.source_platform, source_platform: recording.source_platform,
source_platform_label: recording.source_platform_label, source_platform_label: recording.source_platform_label,
expires_at: recording.expires_at, expires_at: recording.expires_at,
+29 -4
View File
@@ -30,8 +30,9 @@ module AdminHelper
links links
end end
def admin_session_ingest_badge_class(node) def admin_session_ingest_badge_class(role_or_node)
case node.role role = role_or_node.respond_to?(:role) ? role_or_node.role : role_or_node
case role.to_s
when "home" then "badge--ingest-home" when "home" then "badge--ingest-home"
when "lab" then "badge--ingest-lab" when "lab" then "badge--ingest-lab"
when "cloud" then "badge--ingest-cloud" when "cloud" then "badge--ingest-cloud"
@@ -39,8 +40,9 @@ module AdminHelper
end end
end end
def admin_session_ingest_role_label(node) def admin_session_ingest_role_label(role_or_node)
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize) role = role_or_node.respond_to?(:role) ? role_or_node.role : role_or_node
I18n.t("admin.sessions.ingest.role.#{role}", default: role.to_s.humanize)
end end
def admin_session_status_badge_class(status) def admin_session_status_badge_class(status)
@@ -81,6 +83,29 @@ module AdminHelper
end end
end end
def admin_session_client_os_label(session)
case session.client_os.to_s
when "android" then "Android"
when "ios" then "iOS"
else I18n.t("admin.common.dash")
end
end
def admin_session_client_summary(session)
parts = []
parts << admin_session_client_os_label(session) if session.client_os.present?
if session.app_version.present?
ver = session.app_version
ver = "#{ver} (#{session.app_build})" if session.app_build.present?
parts << "app #{ver}"
end
device = [session.device_manufacturer, session.device_model].compact_blank.join(" ")
parts << device if device.present?
parts << "OS #{session.os_version}" if session.os_version.present?
parts << session.carrier if session.carrier.present?
parts.presence&.join(" · ") || I18n.t("admin.common.dash")
end
def admin_format_event_meta(metadata) def admin_format_event_meta(metadata)
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank? return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module Recordings
class ClearTemporaryMediaJob
include Sidekiq::Job
sidekiq_options retry: 3, queue: "default"
def perform(recording_id, reason = "verified")
recording = Recording.find_by(id: recording_id)
return unless recording
Recordings::ClearTemporaryMedia.new(
recording,
reason: reason.to_sym,
force: reason.to_s == "verified"
).call
end
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
module Recordings module Recordings
class PostProcessJob class PostProcessJob
include Sidekiq::Job include Sidekiq::Job
@@ -7,10 +9,30 @@ module Recordings
recording = Recording.find_by(id: recording_id) recording = Recording.find_by(id: recording_id)
return unless recording&.ready? return unless recording&.ready?
Recordings::NotifyReady.new(recording).call begin
Recordings::NotifyReady.new(recording).call
rescue StandardError => e
# SMTP/ntfy giù non deve bloccare VerifyYoutubeReplay né il resto del post-process.
Rails.logger.warn(
"[Recordings::PostProcessJob] notify_failed recording=#{recording.id} " \
"#{e.class}: #{e.message}"
)
end
if recording.temporary_storage?
grace = MatchLiveTv.youtube_replay_verify_grace_secs
Rails.logger.info(
"[Recordings::PostProcessJob] schedule VerifyYoutubeReplay " \
"recording=#{recording.id} grace=#{grace}s"
)
Recordings::VerifyYoutubeReplayJob.perform_in(grace.seconds, recording.id, 0)
return
end
# Solo MatchLiveTV-only: eventuale re-upload manuale/flag legacy (non per live YouTube).
return unless recording.auto_publish_youtube? return unless recording.auto_publish_youtube?
return if recording.youtube_video_id.present? return if recording.youtube_video_id.present?
return if recording.source_platform == "youtube"
Recordings::PublishToYoutubeJob.perform_async(recording.id) Recordings::PublishToYoutubeJob.perform_async(recording.id)
end end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Recordings
# Safety net: elimina copie temp scadute (anche se verify non è mai riuscito).
class PurgeTemporaryMediaJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
scope = Recording.temporary_media_pending_purge
count = 0
scope.find_each do |recording|
unless recording.youtube_verified_at.present?
Rails.logger.warn(
"[Recordings::PurgeTemporaryMediaJob] anomaly_unverified_expiry " \
"recording=#{recording.id} session=#{recording.stream_session_id} " \
"youtube_video_id=#{recording.youtube_video_id.inspect} " \
"temp_expires_at=#{recording.temp_expires_at.inspect}"
)
end
Recordings::ClearTemporaryMedia.new(
recording,
reason: :max_retention,
force: true
).call
count += 1
end
Rails.logger.info("[Recordings::PurgeTemporaryMediaJob] processed=#{count}")
count
end
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
module Recordings
class VerifyYoutubeReplayJob
include Sidekiq::Job
sidekiq_options retry: 0, queue: "default"
def perform(recording_id, attempt = 0)
recording = Recording.find_by(id: recording_id)
return unless recording&.temporary_storage?
return if recording.deleted?
attempt = attempt.to_i
max = MatchLiveTv.youtube_replay_verify_max_attempts
result = Recordings::VerifyYoutubeReplay.new(recording).call
if result.ok?
Recordings::ClearTemporaryMediaJob.perform_async(recording.id, "verified")
return
end
if result.retriable? && attempt + 1 < max
delay = backoff_secs(attempt)
Rails.logger.info(
"[Recordings::VerifyYoutubeReplayJob] retry recording=#{recording.id} " \
"attempt=#{attempt + 1}/#{max} in=#{delay}s msg=#{result.message}"
)
self.class.perform_in(delay.seconds, recording.id, attempt + 1)
return
end
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplayJob] give_up recording=#{recording.id} " \
"attempts=#{attempt + 1} msg=#{result.message} " \
"temp_expires_at=#{recording.temp_expires_at.inspect}"
)
end
private
def backoff_secs(attempt)
base = MatchLiveTv.youtube_replay_verify_base_interval_secs
# 300, 600, 1200, ... capped at 1h
[base * (2**attempt), 3600].min
end
end
end
@@ -8,6 +8,8 @@ module Streams
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
REDIS_CHAIN_KEY = "streams:autoscaler:chain" REDIS_CHAIN_KEY = "streams:autoscaler:chain"
KICK_DEBOUNCE_KEY = "streams:autoscaler:kick"
KICK_DEBOUNCE_SECS = ENV.fetch("STREAM_AUTOSCALE_KICK_DEBOUNCE_SECS", "5").to_i
def self.ensure_chain def self.ensure_chain
return unless redis return unless redis
@@ -17,6 +19,16 @@ module Streams
perform_in(INTERVAL_SECS) perform_in(INTERVAL_SECS)
end end
# Kick immediato (es. NoCapacity / soft-free bassi). Debounce anti-flood Sidekiq.
def self.kick!
return false unless Streams::Autoscaler.enabled?
return false unless redis
return false unless redis.set(KICK_DEBOUNCE_KEY, "1", nx: true, ex: KICK_DEBOUNCE_SECS)
perform_async
true
end
def self.redis def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) @redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError rescue Redis::CannotConnectError
+51 -4
View File
@@ -2,6 +2,8 @@ class Recording < ApplicationRecord
STATUSES = %w[processing ready expired failed].freeze STATUSES = %w[processing ready expired failed].freeze
PRIVACY_STATUSES = %w[public unlisted].freeze PRIVACY_STATUSES = %w[public unlisted].freeze
STORAGE_BACKENDS = %w[local s3].freeze STORAGE_BACKENDS = %w[local s3].freeze
STORAGE_POLICIES = %w[temporary retained none].freeze
REPLAY_SOURCES = %w[youtube matchlivetv none].freeze
belongs_to :stream_session belongs_to :stream_session
belongs_to :team belongs_to :team
@@ -9,6 +11,7 @@ class Recording < ApplicationRecord
validates :status, inclusion: { in: STATUSES } validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES } validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS } validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
validates :storage_policy, inclusion: { in: STORAGE_POLICIES }
scope :not_deleted, -> { where(deleted_at: nil) } scope :not_deleted, -> { where(deleted_at: nil) }
scope :ready, lambda { scope :ready, lambda {
@@ -23,7 +26,16 @@ class Recording < ApplicationRecord
ready.where(expires_at: ..days.days.from_now) ready.where(expires_at: ..days.days.from_now)
} }
scope :expired_pending_purge, lambda { scope :expired_pending_purge, lambda {
not_deleted.where(status: %w[ready failed]).where("expires_at IS NOT NULL AND expires_at <= ?", Time.current) not_deleted
.where(storage_policy: "retained")
.where(status: %w[ready failed])
.where("expires_at IS NOT NULL AND expires_at <= ?", Time.current)
}
scope :temporary_media_pending_purge, lambda {
not_deleted
.where(storage_policy: "temporary")
.where(local_media_purged_at: nil)
.where("temp_expires_at IS NOT NULL AND temp_expires_at <= ?", Time.current)
} }
scope :search_replays, lambda { |query| scope :search_replays, lambda { |query|
q = query.to_s.strip q = query.to_s.strip
@@ -46,19 +58,30 @@ class Recording < ApplicationRecord
end end
def playback_stream_url def playback_stream_url
return nil unless ready? && stream_session_id.present? return nil unless ready? && storage_key.present? && stream_session_id.present?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream" "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream"
end end
def thumbnail_url def thumbnail_url
return youtube_thumbnail_url if thumbnail_storage_key.blank? && youtube_video_id.present?
return nil unless thumbnail_storage_key.present? && stream_session_id.present? return nil unless thumbnail_storage_key.present? && stream_session_id.present?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail" "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
end end
def youtube_thumbnail_url
return nil if youtube_video_id.blank?
return nil if youtube_video_id.to_s.start_with?("mock_")
meta_url = metadata.is_a?(Hash) ? metadata.dig("youtube", "thumbnail_url") : nil
return meta_url if meta_url.present?
"https://i.ytimg.com/vi/#{youtube_video_id}/hqdefault.jpg"
end
def download_api_path def download_api_path
return nil unless ready? return nil unless ready? && storage_key.present?
"/api/v1/recordings/#{id}/download" "/api/v1/recordings/#{id}/download"
end end
@@ -70,6 +93,30 @@ class Recording < ApplicationRecord
"https://www.youtube.com/watch?v=#{youtube_video_id}" "https://www.youtube.com/watch?v=#{youtube_video_id}"
end end
def temporary_storage?
storage_policy == "temporary"
end
def retained_storage?
storage_policy == "retained"
end
def local_media_purged?
local_media_purged_at.present?
end
# Sorgente fisica del player in archivio (non lo storage policy).
def replay_source
return "youtube" if youtube_watch_url.present? || (ready? && youtube_video_id.present?)
return "matchlivetv" if ready? && storage_key.present?
"none"
end
def available_in_archive?
ready? && (youtube_watch_url.present? || youtube_video_id.present? || storage_key.present?)
end
def ready? def ready?
status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?) status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
end end
@@ -154,7 +201,7 @@ class Recording < ApplicationRecord
end end
def playable_on_site? def playable_on_site?
ready? && storage_key.present? available_in_archive?
end end
def days_until_expiry def days_until_expiry
+16 -1
View File
@@ -1,7 +1,8 @@
class StreamSession < ApplicationRecord class StreamSession < ApplicationRecord
include AASM include AASM
PLATFORMS = %w[matchlivetv youtube facebook twitch].freeze LIVE_PLATFORMS = %w[matchlivetv youtube].freeze
PLATFORMS = (LIVE_PLATFORMS + %w[facebook twitch]).freeze
STATUSES = %w[idle connecting live reconnecting paused ended error].freeze STATUSES = %w[idle connecting live reconnecting paused ended error].freeze
PRIVACY_STATUSES = %w[public unlisted private].freeze PRIVACY_STATUSES = %w[public unlisted private].freeze
@@ -20,6 +21,7 @@ class StreamSession < ApplicationRecord
before_validation :normalize_privacy_status before_validation :normalize_privacy_status
before_validation :ensure_publish_token, on: :create before_validation :ensure_publish_token, on: :create
before_validation :snapshot_ingest_from_node, if: -> { stream_node.present? }
scope :broadcasting, -> { where(status: %w[live connecting reconnecting paused]) } scope :broadcasting, -> { where(status: %w[live connecting reconnecting paused]) }
scope :publicly_listed, -> { where(privacy_status: "public") } scope :publicly_listed, -> { where(privacy_status: "public") }
@@ -74,6 +76,14 @@ class StreamSession < ApplicationRecord
end end
end end
def ingest_slug_display
stream_node&.slug.presence || ingest_slug
end
def ingest_role_display
stream_node&.role.presence || ingest_role
end
def rtmp_ingest_url def rtmp_ingest_url
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti). # RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path). # MediaMTX path = live/match_{uuid} (no ?token= nel path).
@@ -208,6 +218,11 @@ class StreamSession < ApplicationRecord
self.privacy_status = "unlisted" if privacy_status == "private" self.privacy_status = "unlisted" if privacy_status == "private"
end end
def snapshot_ingest_from_node
self.ingest_slug = stream_node.slug
self.ingest_role = stream_node.role
end
def record_ended_timestamps! def record_ended_timestamps!
now = Time.current now = Time.current
update!(ended_at: now) if ended_at.nil? update!(ended_at: now) if ended_at.nil?
+32 -15
View File
@@ -3,6 +3,7 @@
module Analytics module Analytics
class Aggregate class Aggregate
BATCH = 500 BATCH = 500
UPSERT_RETRIES = 3
def call def call
loop do loop do
@@ -45,13 +46,15 @@ module Analytics
now = Time.current now = Time.current
grouped.each do |(day, page_path, device, cell_x, cell_y), count| grouped.each do |(day, page_path, device, cell_x, cell_y), count|
cell = AnalyticsPageCell.find_or_initialize_by( with_unique_retry do
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y cell = AnalyticsPageCell.find_or_initialize_by(
) day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
cell[counter_attr] = cell[counter_attr].to_i + count )
cell.created_at ||= now cell[counter_attr] = cell[counter_attr].to_i + count
cell.updated_at = now cell.created_at ||= now
cell.save! cell.updated_at = now
cell.save!
end
end end
end end
@@ -76,14 +79,28 @@ module Analytics
now = Time.current now = Time.current
grouped.each do |(day, page_path, device), vals| grouped.each do |(day, page_path, device), vals|
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device) with_unique_retry do
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews] stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples] stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum] stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
stat.created_at ||= now stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
stat.updated_at = now stat.created_at ||= now
stat.save! stat.updated_at = now
stat.save!
end
end
end
def with_unique_retry
attempts = 0
begin
attempts += 1
yield
rescue ActiveRecord::RecordNotUnique
raise if attempts >= UPSERT_RETRIES
retry
end end
end end
end end
+9 -1
View File
@@ -31,7 +31,15 @@ module Analytics
end end
AnalyticsEvent.insert_all(rows) if rows.any? AnalyticsEvent.insert_all(rows) if rows.any?
Analytics::Aggregate.new.call if rows.any? if rows.any?
begin
Analytics::Aggregate.new.call
rescue ActiveRecord::RecordNotUnique => e
# Dopo i retry interni: non far fallire la richiesta analytics.
Rails.logger.warn("[analytics] aggregate race after retries, enqueue job: #{e.message}")
Analytics::AggregateJob.perform_later
end
end
Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false) Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false)
end end
+36 -1
View File
@@ -4,6 +4,9 @@ module Mediamtx
class Client class Client
class Error < StandardError; end class Error < StandardError; end
CREATE_PATH_RETRIES = -> { ENV.fetch("MEDIAMTX_CREATE_RETRIES", "5").to_i }
CREATE_PATH_RETRY_BASE_SECS = -> { ENV.fetch("MEDIAMTX_CREATE_RETRY_BASE_SECS", "0.4").to_f }
def self.for_session(session) def self.for_session(session)
new(base_url: session.mediamtx_api_base_url) new(base_url: session.mediamtx_api_base_url)
end end
@@ -19,6 +22,19 @@ module Mediamtx
attr_reader :base_url attr_reader :base_url
# Health probe for CPX readiness (GET /v3/paths/list).
def reachable?(timeout: 2)
conn = Faraday.new(url: @base_url) do |f|
f.adapter Faraday.default_adapter
f.options.open_timeout = timeout
f.options.timeout = timeout
end
response = conn.get("/v3/paths/list")
response.success?
rescue Faraday::Error
false
end
def create_path(session) def create_path(session)
path = session.mediamtx_path_name path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
@@ -30,7 +46,9 @@ module Mediamtx
body[:alwaysAvailable] = true body[:alwaysAvailable] = true
body[:alwaysAvailableFile] = slate_file_path(session) body[:alwaysAvailableFile] = slate_file_path(session)
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq. # YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) response = with_connection_retries("create_path #{path}") do
@conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
end
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
raise Error, "MediaMTX path create failed: #{response.status} #{err}" raise Error, "MediaMTX path create failed: #{response.status} #{err}"
@@ -161,6 +179,23 @@ module Mediamtx
private private
def with_connection_retries(label)
attempts = [CREATE_PATH_RETRIES.call, 1].max
base = CREATE_PATH_RETRY_BASE_SECS.call
try = 0
begin
try += 1
yield
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
raise if try >= attempts
sleep_secs = base * (2**(try - 1))
Rails.logger.warn("[Mediamtx::Client] #{label} retry #{try}/#{attempts} after #{e.class}: #{e.message} (sleep #{sleep_secs}s)")
sleep(sleep_secs)
retry
end
end
def recording_body(session, enabled:) def recording_body(session, enabled:)
ent = session.match.team.entitlements ent = session.match.team.entitlements
can_record = ent.recording_enabled_for_mediamtx? can_record = ent.recording_enabled_for_mediamtx?
@@ -0,0 +1,72 @@
# frozen_string_literal: true
module Recordings
# Rimuove solo l'MP4 temporaneo su object storage. Non soft-delete, non tocca YouTube.
class ClearTemporaryMedia
def initialize(recording, reason: :verified, force: false)
@recording = recording
@reason = reason
@force = force
end
def call
unless @recording.temporary_storage?
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] skip recording=#{@recording.id} not_temporary"
)
return @recording
end
if @recording.local_media_purged?
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] already_purged recording=#{@recording.id}"
)
return @recording
end
unless allowed?
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] skip recording=#{@recording.id} " \
"reason=not_verified_and_not_expired force=#{@force}"
)
return @recording
end
delete_video_object!
@recording.update!(
storage_key: nil,
byte_size: nil,
local_media_purged_at: Time.current
)
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] purged recording=#{@recording.id} " \
"reason=#{@reason} youtube_verified=#{@recording.youtube_verified_at.present?} " \
"youtube_video_id=#{@recording.youtube_video_id.inspect}"
)
@recording
end
private
def allowed?
return true if @force
return true if @recording.youtube_verified_at.present?
return true if @recording.temp_expires_at.present? && @recording.temp_expires_at <= Time.current
false
end
def delete_video_object!
key = @recording.storage_key
return if key.blank?
Recordings::Storage.new.delete(key: key)
rescue Recordings::Storage::Error => e
# File già assente → ok (idempotente)
Rails.logger.info(
"[Recordings::ClearTemporaryMedia] storage delete recording=#{@recording.id}: #{e.message}"
)
end
end
end
@@ -1,3 +1,5 @@
# frozen_string_literal: true
module Recordings module Recordings
class FinalizeSession class FinalizeSession
def initialize(session) def initialize(session)
@@ -5,29 +7,69 @@ module Recordings
end end
def call def call
team = @session.match.team policy = Recordings::StoragePolicy.call(@session)
return unless team.entitlements.can_create_recordings? return if policy == Recordings::StoragePolicy::NONE
retention_days = team.entitlements.recording_retention_days team = @session.match.team
expires_at = retention_days.positive? ? retention_days.days.from_now : nil attrs = attributes_for(policy, team)
recording = Recording.find_or_initialize_by(stream_session: @session) recording = Recording.find_or_initialize_by(stream_session: @session)
recording.assign_attributes( if skip_reinitialize?(recording)
Rails.logger.info(
"[Recordings::FinalizeSession] skip already-finalized " \
"recording=#{recording.id} status=#{recording.status}"
)
return recording
end
recording.assign_attributes(attrs)
recording.save!
Rails.logger.info(
"[Recordings::FinalizeSession] session=#{@session.id} recording=#{recording.id} " \
"storage_policy=#{policy} expires_at=#{recording.expires_at.inspect} " \
"temp_expires_at=#{recording.temp_expires_at.inspect}"
)
recording
end
private
def skip_reinitialize?(recording)
return false unless recording.persisted?
recording.ready? ||
recording.status == "processing" ||
recording.storage_key.present?
end
def attributes_for(policy, team)
{
team: team, team: team,
status: "processing", status: "processing",
title: default_title, title: default_title,
privacy_status: privacy_from_session, privacy_status: privacy_from_session,
storage_path: @session.mediamtx_path_name, storage_path: @session.mediamtx_path_name,
recorded_at: @session.ended_at || Time.current, recorded_at: @session.ended_at || Time.current,
expires_at: expires_at, storage_policy: policy,
expires_at: archive_expires_at(policy, team),
temp_expires_at: temp_expires_at_for(policy),
error_message: nil, error_message: nil,
metadata: initial_metadata(team) metadata: initial_metadata(policy, team)
) }
recording.save!
recording
end end
private def archive_expires_at(policy, team)
return nil if policy == Recordings::StoragePolicy::TEMPORARY
retention_days = team.entitlements.recording_retention_days
retention_days.positive? ? retention_days.days.from_now : nil
end
def temp_expires_at_for(policy)
return nil unless policy == Recordings::StoragePolicy::TEMPORARY
MatchLiveTv.youtube_temp_replay_retention_hours.hours.from_now
end
def default_title def default_title
match = @session.match match = @session.match
@@ -38,12 +80,13 @@ module Recordings
@session.privacy_status == "public" ? "public" : "unlisted" @session.privacy_status == "public" ? "public" : "unlisted"
end end
def initial_metadata(team) def initial_metadata(policy, team)
ent = team.entitlements
{ {
"source_platform" => @session.platform, "source_platform" => @session.platform,
"session_privacy" => @session.privacy_status, "session_privacy" => @session.privacy_status,
"auto_publish_youtube" => ent.premium_full? && ent.youtube_enabled? && @session.platform == "youtube", # Re-upload automatico disabilitato: le live YouTube usano VerifyYoutubeReplay.
"auto_publish_youtube" => false,
"storage_policy" => policy,
"ai" => {} "ai" => {}
} }
end end
@@ -0,0 +1,92 @@
# frozen_string_literal: true
require "cgi"
require "fileutils"
require "net/http"
require "uri"
module Recordings
# Recupera i segmenti MediaMTX dal disco del CPX (agent :9100) verso un tmpdir locale.
class PullFromCloudNode
class Error < StandardError; end
def initialize(session)
@session = session
end
def applicable?
node = @session.stream_node
node.present? && node.role == "cloud" && agent_url.present?
end
# @return [String, nil] directory con i file, o nil se il nodo non è cloud / 404
def fetch
return unless applicable?
dest = Dir.mktmpdir("mltv-cpx-rec-")
uri = recordings_uri
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = ENV.fetch("STREAM_NODE_RECORDINGS_PULL_TIMEOUT", "180").to_i
req = Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
res = http.request(req)
if res.is_a?(Net::HTTPNotFound)
FileUtils.remove_entry(dest)
return nil
end
unless res.is_a?(Net::HTTPSuccess) && res.body.present?
FileUtils.remove_entry(dest)
raise Error, "agent GET recordings HTTP #{res.code} #{res.body.to_s.truncate(200)}"
end
tar_path = File.join(dest, "recordings.tar.gz")
File.binwrite(tar_path, res.body)
unpack!(tar_path, dest)
FileUtils.rm_f(tar_path)
dest
rescue StandardError
FileUtils.remove_entry(dest) if dest && Dir.exist?(dest)
raise
end
def cleanup_remote!
return unless applicable?
uri = recordings_uri
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = 15
req = Net::HTTP::Delete.new(uri)
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
res = http.request(req)
return if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPNotFound)
Rails.logger.warn(
"[Recordings::PullFromCloudNode] delete HTTP #{res.code} session=#{@session.id}"
)
rescue StandardError => e
Rails.logger.warn("[Recordings::PullFromCloudNode] delete #{e.class}: #{e.message}")
end
private
def recordings_uri
path = @session.mediamtx_path_name.to_s
URI.parse("#{agent_url.chomp('/')}/recordings/#{CGI.escape(path)}")
end
def agent_url
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || @session.stream_node&.relay_agent_url
end
def agent_secret
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end
def unpack!(tar_path, dest)
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
raise Error, "tar extract failed" unless ok
end
end
end
@@ -0,0 +1,36 @@
# frozen_string_literal: true
module Recordings
# Decisione centralizzata: temporary (YouTube) | retained (HLS) | none.
class StoragePolicy
TEMPORARY = "temporary"
RETAINED = "retained"
NONE = "none"
POLICIES = [TEMPORARY, RETAINED, NONE].freeze
def self.call(session)
new(session).call
end
def initialize(session)
@session = session
end
def call
team = @session.match.team
ent = team.entitlements
return NONE unless ent.can_create_recordings?
if youtube_destination?
TEMPORARY
else
RETAINED
end
end
def youtube_destination?
@session.platform.to_s == "youtube"
end
end
end
@@ -9,8 +9,9 @@ module Recordings
def call def call
recording = Recording.find_by(stream_session: @session) recording = Recording.find_by(stream_session: @session)
return unless recording&.status == "processing" return unless recording&.status == "processing"
return if recording.storage_key.present?
source_files = local_source_files source_files = collect_source_files
if source_files.empty? if source_files.empty?
fail_recording!(recording, "Nessun file di registrazione trovato") fail_recording!(recording, "Nessun file di registrazione trovato")
cleanup_mediamtx_path cleanup_mediamtx_path
@@ -35,23 +36,52 @@ module Recordings
error_message: nil error_message: nil
) )
Rails.logger.info(
"[Recordings::UploadFromSession] ready recording=#{recording.id} " \
"storage_policy=#{recording.storage_policy} key=#{storage_key} bytes=#{byte_size}"
)
cleanup_local_sources(source_files, merged_path) cleanup_local_sources(source_files, merged_path)
cleanup_cloud_pull!
cleanup_remote_recordings!
cleanup_mediamtx_path cleanup_mediamtx_path
Recordings::PostProcessJob.perform_async(recording.id) Recordings::PostProcessJob.perform_async(recording.id)
recording recording
rescue Error, Recordings::Storage::Error => e rescue Error, Recordings::Storage::Error, Recordings::PullFromCloudNode::Error => e
recording = Recording.find_by(stream_session: @session) recording = Recording.find_by(stream_session: @session)
fail_recording!(recording, e.message) if recording fail_recording!(recording, e.message) if recording
raise raise
ensure ensure
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path) FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
cleanup_cloud_pull! unless recording_ready?
end end
private private
def collect_source_files
files = local_source_files
return files if files.any?
puller = Recordings::PullFromCloudNode.new(@session)
return [] unless puller.applicable?
3.times do |i|
cleanup_cloud_pull!
@cloud_pull_dir = puller.fetch
files = scan_recording_files(@cloud_pull_dir)
return files if files.any?
sleep 2 if i < 2
end
[]
end
def local_source_files def local_source_files
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name) scan_recording_files(File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name))
return [] unless Dir.exist?(base) end
def scan_recording_files(base)
return [] if base.blank? || !Dir.exist?(base)
Dir.glob(File.join(base, "**", "*")) Dir.glob(File.join(base, "**", "*"))
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) } .select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
@@ -83,7 +113,11 @@ module Recordings
end end
def object_key(recording) def object_key(recording)
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4" if recording.temporary_storage?
"temporary_replays/teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
else
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
end
end end
def cleanup_local_sources(source_files, merged_path) def cleanup_local_sources(source_files, merged_path)
@@ -100,6 +134,24 @@ module Recordings
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}") Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
end end
def cleanup_cloud_pull!
return if @cloud_pull_dir.blank? || !Dir.exist?(@cloud_pull_dir)
FileUtils.remove_entry(@cloud_pull_dir)
@cloud_pull_dir = nil
rescue StandardError
nil
end
def cleanup_remote_recordings!
Recordings::PullFromCloudNode.new(@session).cleanup_remote!
end
def recording_ready?
rec = Recording.find_by(stream_session: @session)
rec&.status == "ready"
end
def fail_recording!(recording, message) def fail_recording!(recording, message)
recording.update!(status: "failed", error_message: message) recording.update!(status: "failed", error_message: message)
cleanup_mediamtx_path cleanup_mediamtx_path
@@ -0,0 +1,86 @@
# frozen_string_literal: true
module Recordings
# Collega la live YouTube (broadcast_id) al VOD e aggiorna metadata recording.
class VerifyYoutubeReplay
class Error < StandardError; end
Result = Struct.new(:status, :message, keyword_init: true) do
def ok?
status == :ok
end
def retriable?
status == :pending
end
end
def initialize(recording)
@recording = recording
end
def call
unless @recording.temporary_storage?
return Result.new(status: :skipped, message: "not_temporary")
end
if @recording.youtube_verified_at.present? && @recording.youtube_video_id.present?
return Result.new(status: :ok, message: "already_verified")
end
session = @recording.stream_session
broadcast_id = session&.youtube_broadcast_id
if broadcast_id.blank?
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplay] missing broadcast_id recording=#{@recording.id}"
)
return Result.new(status: :failed, message: "missing_broadcast_id")
end
info = Youtube::VodStatus.new(@recording.team, channel: "team").fetch(broadcast_id)
unless info.ready
Rails.logger.info(
"[Recordings::VerifyYoutubeReplay] not_ready recording=#{@recording.id} " \
"video=#{broadcast_id} upload_status=#{info.upload_status.inspect}"
)
return Result.new(status: :pending, message: "vod_not_ready")
end
apply_verified!(info)
Rails.logger.info(
"[Recordings::VerifyYoutubeReplay] ok recording=#{@recording.id} " \
"youtube_video_id=#{info.video_id}"
)
Result.new(status: :ok, message: "verified")
rescue Youtube::VodStatus::Error => e
Rails.logger.warn(
"[Recordings::VerifyYoutubeReplay] api_error recording=#{@recording.id}: #{e.message}"
)
Result.new(status: :pending, message: e.message)
end
private
def apply_verified!(info)
meta = @recording.metadata.is_a?(Hash) ? @recording.metadata.deep_dup : {}
yt = meta.fetch("youtube", {}).merge(
"broadcast_id" => @recording.stream_session.youtube_broadcast_id,
"thumbnail_url" => info.thumbnail_url,
"privacy_status" => info.privacy_status,
"upload_status" => info.upload_status,
"verified_via" => "live_broadcast"
).compact
attrs = {
youtube_video_id: info.video_id,
youtube_verified_at: Time.current,
youtube_published_at: @recording.youtube_published_at || Time.current,
metadata: meta.merge("youtube" => yt)
}
attrs[:duration_secs] = info.duration_secs if info.duration_secs.to_i.positive?
attrs[:title] = info.title if info.title.present? && @recording.title.blank?
@recording.update!(attrs)
end
end
end
@@ -0,0 +1,72 @@
# frozen_string_literal: true
module Sessions
# Normalizza e applica fingerprint del client (OS, app, device, operatore)
# sulla sessione, a create e/o a ogni telemetry.
class ApplyClientInfo
OS_VALUES = %w[android ios].freeze
MAX_LEN = 80
ATTRS = %i[
client_os app_version app_build device_manufacturer device_model os_version carrier
].freeze
def self.call(session, raw)
new(session, raw).call
end
def initialize(session, raw)
@session = session
@raw = normalize_hash(raw)
end
def call
attrs = extract_attrs
return @session if attrs.empty?
@session.assign_attributes(attrs)
@session.save! if @session.persisted? && @session.changed?
@session
end
private
def normalize_hash(raw)
return {} if raw.blank?
data = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
data = data.to_h if data.respond_to?(:to_h)
data.with_indifferent_access
rescue StandardError
{}
end
def extract_attrs
attrs = {}
os = @raw[:os].presence || @raw[:client_os].presence
os = os.to_s.downcase.strip
attrs[:client_os] = os if OS_VALUES.include?(os)
{
app_version: %i[app_version version],
app_build: %i[app_build build build_number],
device_manufacturer: %i[device_manufacturer manufacturer],
device_model: %i[device_model model],
os_version: %i[os_version system_version],
carrier: %i[carrier network_operator operator]
}.each do |column, keys|
value = keys.map { |k| @raw[k] }.find(&:present?)
next if value.blank?
attrs[column] = truncate(value.to_s.strip)
end
attrs
end
def truncate(value)
value.bytesize <= MAX_LEN ? value : value.byteslice(0, MAX_LEN)
end
end
end
+38 -3
View File
@@ -26,6 +26,7 @@ module Sessions
target_fps: @params[:target_fps] || 30, target_fps: @params[:target_fps] || 30,
status: "idle" status: "idle"
) )
Sessions::ApplyClientInfo.call(session, @params[:client])
youtube_channel = nil youtube_channel = nil
if session.platform == "youtube" if session.platform == "youtube"
@@ -46,22 +47,56 @@ module Sessions
{ {
created: true, created: true,
platform: session.platform, platform: session.platform,
stream_node: session.stream_node&.slug stream_node: session.stream_node&.slug,
} client_os: session.client_os,
app_version: session.app_version,
device_model: session.device_model
}.compact
) )
end end
kick_autoscaler_if_soft_limit!
if session.platform == "youtube" if session.platform == "youtube"
YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel) YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel)
end end
session session
rescue Streams::NodeRegistry::NoCapacityError => e rescue Streams::NodeRegistry::NoCapacityError => e
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted") raise_no_capacity!(e)
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
rescue Mediamtx::Client::Error => e
if e.message.to_s.match?(/failed to open|Connection refused|Timeout|timed out/i)
raise Streams::IngestUnavailableError, e.message
end
raise
end end
private private
def raise_no_capacity!(error)
if Streams::Autoscaler.enabled?
Streams::AutoscalerJob.kick!
raise Teams::EntitlementError.new(
"Capacità streaming in espansione. Riprova tra poco.",
code: "stream_capacity_scaling"
)
end
raise Teams::EntitlementError.new(error.message, code: "stream_capacity_exhausted")
end
def kick_autoscaler_if_soft_limit!
return unless Streams::Autoscaler.enabled?
free = Streams::Autoscaler.metrics[:free_slots].to_i
return if free > Streams::Autoscaler.soft_free_slots
Streams::AutoscalerJob.kick!
end
def assert_youtube_channel!(youtube_channel) def assert_youtube_channel!(youtube_channel)
resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel) resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel)
if resolver.resolve.blank? if resolver.resolve.blank?
+1 -1
View File
@@ -12,7 +12,7 @@ module Sessions
complete_youtube_broadcast! if @session.youtube_broadcast_id.present? complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
recording = Recordings::FinalizeSession.new(@session).call recording = Recordings::FinalizeSession.new(@session).call
Recordings::UploadJob.perform_async(@session.id) if recording Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
remove_mediamtx_paths! remove_mediamtx_paths!
log_event("ended") log_event("ended")
@@ -136,6 +136,8 @@ module Streams
def reconcile! def reconcile!
actions = [] actions = []
actions.concat(promote_provisioning_nodes!)
actions.concat(reclaim_stuck_provisioning!)
m = self.class.metrics m = self.class.metrics
if need_capacity?(m) && can_provision?(m) if need_capacity?(m) && can_provision?(m)
@@ -170,6 +172,30 @@ module Streams
private private
def promote_provisioning_nodes!
actions = []
StreamNode.where(status: "provisioning").find_each do |node|
next unless Streams::NodeHealth.promote_if_healthy!(node)
actions << :"ready_#{node.slug}"
Rails.logger.info("[Streams::Autoscaler] promoted #{node.slug} to ready")
end
actions
end
def reclaim_stuck_provisioning!
actions = []
stuck_after = ENV.fetch("STREAM_NODE_PROVISIONING_STUCK_MINUTES", "15").to_i.minutes.ago
StreamNode.where(status: "provisioning").where("created_at < ?", stuck_after).find_each do |node|
@provisioner.decommission!(node)
actions << :"reclaim_#{node.slug}"
Rails.logger.warn("[Streams::Autoscaler] decommissioned stuck provisioning #{node.slug}")
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
Rails.logger.warn("[Streams::Autoscaler] reclaim #{node.slug}: #{e.message}")
end
actions
end
def need_capacity?(m) def need_capacity?(m)
m[:free_slots] <= self.class.soft_free_slots m[:free_slots] <= self.class.soft_free_slots
end end
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module Streams
# Ingest MediaMTX non raggiungibile (es. CPX ancora in bootstrap). API → 503 retryable.
class IngestUnavailableError < StandardError
attr_reader :code
def initialize(message = "Ingest temporaneamente non disponibile", code: "stream_ingest_unavailable")
super(message)
@code = code
end
end
end
@@ -0,0 +1,27 @@
# frozen_string_literal: true
module Streams
# Probe reachability of MediaMTX API on a stream node (:9997).
class NodeHealth
def self.mediamtx_up?(node, timeout: 2)
new(node, timeout: timeout).mediamtx_up?
end
def self.promote_if_healthy!(node, timeout: 2)
return false unless node.status == "provisioning"
return false unless mediamtx_up?(node, timeout: timeout)
node.update!(status: "ready", last_health_at: Time.current)
true
end
def initialize(node, timeout: 2)
@node = node
@timeout = timeout
end
def mediamtx_up?
Mediamtx::Client.new(base_url: @node.api_base_url).reachable?(timeout: @timeout)
end
end
end
@@ -80,11 +80,15 @@ module Streams
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated, urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname) private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
StreamNode.create!( # Simulated/lab che riusa MediaMTX home: subito ready. Cloud reale: provisioning
# finché :9997 risponde (evita allocate → Faraday Connection refused → 500).
initial_status = simulated ? "ready" : "provisioning"
node = StreamNode.create!(
slug: slug, slug: slug,
hostname: hostname, hostname: hostname,
role: role, role: role,
status: "ready", status: initial_status,
provider: provider_name_for(cloud, role: role), provider: provider_name_for(cloud, role: role),
provider_instance_id: instance.id, provider_instance_id: instance.id,
rtmp_base_url: urls.fetch(:rtmp_base_url), rtmp_base_url: urls.fetch(:rtmp_base_url),
@@ -102,6 +106,34 @@ module Streams
"cloud_raw" => instance.raw "cloud_raw" => instance.raw
} }
) )
wait_until_mediamtx_ready!(node) unless simulated
node.reload
end
def wait_until_mediamtx_ready!(node, timeout: nil, interval: nil)
timeout ||= ENV.fetch("STREAM_NODE_READY_TIMEOUT_SECS", "180").to_i
interval ||= ENV.fetch("STREAM_NODE_READY_POLL_SECS", "3").to_f
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
loop do
if Streams::NodeHealth.promote_if_healthy!(node)
Rails.logger.info("[Streams::NodeProvisioner] #{node.slug} MediaMTX ready")
return node
end
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
if remaining <= 0
Rails.logger.warn(
"[Streams::NodeProvisioner] #{node.slug} ancora provisioning dopo #{timeout}s " \
"(api=#{node.api_base_url}); autoscaler continuerà a promuovere"
)
return node
end
sleep([interval, remaining].min)
node.reload
end
end end
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:) def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
@@ -0,0 +1,85 @@
# frozen_string_literal: true
module Youtube
# Stato VOD dopo una live (broadcast_id ≈ video_id su YouTube).
class VodStatus
class Error < StandardError; end
Result = Struct.new(
:ready,
:video_id,
:title,
:duration_secs,
:thumbnail_url,
:privacy_status,
:upload_status,
keyword_init: true
)
def initialize(team, channel: "team")
@team = team
@channel = channel
end
def fetch(video_id)
raise Error, "video_id mancante" if video_id.blank?
if mock_or_unconfigured?(video_id)
return Result.new(
ready: true,
video_id: video_id,
title: nil,
duration_secs: nil,
thumbnail_url: nil,
privacy_status: "unlisted",
upload_status: "processed"
)
end
client = authorized_client
item = client.list_videos("snippet,contentDetails,status", id: video_id).items&.first
return Result.new(ready: false, video_id: video_id) if item.blank?
upload_status = item.status&.upload_status.to_s
ready = upload_status.in?(%w[processed uploaded]) ||
(item.snippet.present? && upload_status != "deleted" && upload_status != "rejected" && upload_status != "failed")
Result.new(
ready: ready,
video_id: item.id,
title: item.snippet&.title,
duration_secs: parse_duration(item.content_details&.duration),
thumbnail_url: item.snippet&.thumbnails&.high&.url || item.snippet&.thumbnails&.default&.url,
privacy_status: item.status&.privacy_status,
upload_status: upload_status
)
rescue Google::Apis::Error => e
raise Error, e.message
end
private
def mock_or_unconfigured?(video_id)
video_id.to_s.start_with?("mock_") ||
ENV["YOUTUBE_CLIENT_ID"].blank? ||
CredentialResolver.new(@team, channel: @channel).resolve.blank?
end
def authorized_client
credential = CredentialResolver.new(@team, channel: @channel).resolve
raise Error, "Credenziali YouTube non disponibili" if credential.blank?
OauthRefresh.new(credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
end
def parse_duration(iso)
return nil if iso.blank?
# PT1H2M3S
match = iso.match(/\APT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?\z/)
return nil unless match
match[1].to_i * 3600 + match[2].to_i * 60 + match[3].to_i
end
end
end
@@ -107,6 +107,7 @@
<tr> <tr>
<th><%= t("admin.dashboard.sessions.table.match") %></th> <th><%= t("admin.dashboard.sessions.table.match") %></th>
<th><%= t("admin.dashboard.sessions.table.status") %></th> <th><%= t("admin.dashboard.sessions.table.status") %></th>
<th><%= t("admin.dashboard.sessions.table.client") %></th>
<th><%= t("admin.dashboard.sessions.table.ingest") %></th> <th><%= t("admin.dashboard.sessions.table.ingest") %></th>
<th><%= t("admin.dashboard.sessions.table.start") %></th> <th><%= t("admin.dashboard.sessions.table.start") %></th>
<th><%= t("admin.dashboard.sessions.table.link") %></th> <th><%= t("admin.dashboard.sessions.table.link") %></th>
@@ -118,6 +119,7 @@
<tr> <tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td> <td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td> <td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td class="muted"><%= admin_session_client_summary(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td> <td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td> <td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
<td> <td>
@@ -1,8 +1,11 @@
<% node = session.stream_node %> <% slug = session.ingest_slug_display %>
<% if node %> <% role = session.ingest_role_display %>
<% if slug.present? %>
<div class="admin-ingest"> <div class="admin-ingest">
<code class="admin-ingest__slug"><%= node.slug %></code> <code class="admin-ingest__slug"><%= slug %></code>
<span class="badge <%= admin_session_ingest_badge_class(node) %>"><%= admin_session_ingest_role_label(node) %></span> <% if role.present? %>
<span class="badge <%= admin_session_ingest_badge_class(role) %>"><%= admin_session_ingest_role_label(role) %></span>
<% end %>
</div> </div>
<% else %> <% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span> <span class="muted"><%= t("admin.sessions.ingest.none") %></span>
@@ -26,7 +26,7 @@
<span><%= t("admin.sessions.index.filters.platform") %></span> <span><%= t("admin.sessions.index.filters.platform") %></span>
<%= select_tag :platform, <%= select_tag :platform,
options_for_select( options_for_select(
[[t("admin.sessions.index.filters.any"), ""]] + StreamSession::PLATFORMS.map { |p| [p, p] }, [[t("admin.sessions.index.filters.any"), ""]] + StreamSession::LIVE_PLATFORMS.map { |p| [p, p] },
@filters[:platform] @filters[:platform]
) %> ) %>
</label> </label>
@@ -83,6 +83,7 @@
<th><%= t("admin.sessions.index.table.ended") %></th> <th><%= t("admin.sessions.index.table.ended") %></th>
<th><%= t("admin.sessions.index.table.duration") %></th> <th><%= t("admin.sessions.index.table.duration") %></th>
<th><%= t("admin.sessions.index.table.ingest") %></th> <th><%= t("admin.sessions.index.table.ingest") %></th>
<th><%= t("admin.sessions.index.table.client") %></th>
<th><%= t("admin.sessions.index.table.disconnects") %></th> <th><%= t("admin.sessions.index.table.disconnects") %></th>
<th><%= t("admin.sessions.index.table.link") %></th> <th><%= t("admin.sessions.index.table.link") %></th>
<th></th> <th></th>
@@ -110,6 +111,7 @@
<td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td> <td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td>
<td class="muted"><%= admin_session_duration_label(s) %></td> <td class="muted"><%= admin_session_duration_label(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td> <td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted admin-table-sub"><%= admin_session_client_summary(s) %></td>
<td><%= s.disconnection_count %></td> <td><%= s.disconnection_count %></td>
<td> <td>
<div class="admin-link-compact"> <div class="admin-link-compact">
+46 -6
View File
@@ -67,6 +67,38 @@
<dt><%= t("admin.sessions.show.fields.platform") %></dt> <dt><%= t("admin.sessions.show.fields.platform") %></dt>
<dd><%= @session.platform %></dd> <dd><%= @session.platform %></dd>
</div> </div>
<div>
<dt><%= t("admin.sessions.show.fields.client_os") %></dt>
<dd><%= admin_session_client_os_label(@session) %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.app_version") %></dt>
<dd>
<% if @session.app_version.present? %>
<%= @session.app_version %>
<% if @session.app_build.present? %>
<span class="muted">(<%= @session.app_build %>)</span>
<% end %>
<% else %>
<%= t("admin.common.dash") %>
<% end %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.device") %></dt>
<dd>
<% device = [@session.device_manufacturer, @session.device_model].compact_blank.join(" ") %>
<%= device.presence || t("admin.common.dash") %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.os_version") %></dt>
<dd><%= @session.os_version.presence || t("admin.common.dash") %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.carrier") %></dt>
<dd><%= @session.carrier.presence || t("admin.common.dash") %></dd>
</div>
<div> <div>
<dt><%= t("admin.sessions.show.fields.privacy") %></dt> <dt><%= t("admin.sessions.show.fields.privacy") %></dt>
<dd><%= @session.privacy_status %></dd> <dd><%= @session.privacy_status %></dd>
@@ -130,12 +162,20 @@
<div> <div>
<dt><%= t("admin.sessions.show.fields.node") %></dt> <dt><%= t("admin.sessions.show.fields.node") %></dt>
<dd> <dd>
<% if @session.stream_node %> <% slug = @session.ingest_slug_display %>
<code><%= @session.stream_node.slug %></code> <% role = @session.ingest_role_display %>
<span class="badge <%= admin_session_ingest_badge_class(@session.stream_node) %>"> <% if slug.present? %>
<%= admin_session_ingest_role_label(@session.stream_node) %> <code><%= slug %></code>
</span> <% if role.present? %>
<span class="muted">(<%= @session.stream_node.provider %>)</span> <span class="badge <%= admin_session_ingest_badge_class(role) %>">
<%= admin_session_ingest_role_label(role) %>
</span>
<% end %>
<% if @session.stream_node %>
<span class="muted">(<%= @session.stream_node.provider %>)</span>
<% else %>
<span class="muted"><%= t("admin.sessions.ingest.decommissioned") %></span>
<% end %>
<% else %> <% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span> <span class="muted"><%= t("admin.sessions.ingest.none") %></span>
<% end %> <% end %>
+1 -1
View File
@@ -9,7 +9,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= yield :head %> <%= yield :head %>
<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=68"> <link rel="stylesheet" href="/marketing.css?v=76">
</head> </head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %> <%= render "shared/cookie_banner" %>
@@ -7,7 +7,7 @@
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= 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=68"> <link rel="stylesheet" href="/marketing.css?v=76">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -63,6 +63,13 @@
</div> </div>
</section> </section>
<section class="section wrap" aria-labelledby="contacts-social-title">
<div class="features-panel contacts-social">
<h2 id="contacts-social-title"><%= t("pages.contacts.social_title") %></h2>
<%= render "shared/social_links", modifier: "panel" %>
</div>
</section>
<section class="section wrap" aria-labelledby="contacts-form-title"> <section class="section wrap" aria-labelledby="contacts-form-title">
<div class="contacts-form-panel"> <div class="contacts-form-panel">
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2> <h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
+29 -21
View File
@@ -14,6 +14,33 @@
<h2><%= t("replay.show.processing_title") %></h2> <h2><%= t("replay.show.processing_title") %></h2>
<p><%= t("replay.show.processing_body") %></p> <p><%= t("replay.show.processing_body") %></p>
</div> </div>
<% elsif @recording.ready? && @recording.replay_source == "youtube" && @recording.youtube_video_id.present? && !@recording.youtube_video_id.to_s.start_with?("mock_") %>
<div class="live-player-wrap live-player-wrap--embed">
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
title="<%= @recording.title_or_default %>"
class="live-player-embed"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
<%= render "public/live/player_overlays",
match: @match,
session: @session,
stream_closed: true,
on_air: false,
badge_label: t("replay.show.badge_label"),
badge_class: "badge-ended" %>
</div>
<p class="replay-show__meta-line">
<%= l_local(@recording.recorded_at_or_fallback) %>
· <%= t("replay.show.meta_line_duration", value: @recording.duration_label) %>
· <%= @recording.views_label %>
<% if @recording.source_platform_label != "—" %>
· <%= @recording.source_platform_label %>
<% end %>
</p>
<p class="replay-show__meta-line">
<%= t("replay.show.youtube_only_body") %>
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
</p>
<% elsif @recording.ready? && @recording.storage_key.present? %> <% elsif @recording.ready? && @recording.storage_key.present? %>
<div class="live-player-wrap"> <div class="live-player-wrap">
<video id="replay-player" controls playsinline preload="metadata" <video id="replay-player" controls playsinline preload="metadata"
@@ -43,9 +70,9 @@
</p> </p>
<% ent = @recording.team.entitlements %> <% ent = @recording.team.entitlements %>
<% if (ent.phone_download_enabled? && (logged_in? || @recording.unlisted?)) || @recording.youtube_watch_url %> <% if (ent.phone_download_enabled? && @recording.storage_key.present? && (logged_in? || @recording.unlisted?)) || @recording.youtube_watch_url %>
<div class="replay-show__actions"> <div class="replay-show__actions">
<% if ent.phone_download_enabled? && (logged_in? || @recording.unlisted?) %> <% if ent.phone_download_enabled? && @recording.storage_key.present? && (logged_in? || @recording.unlisted?) %>
<%= link_to t("replay.show.download_link"), public_replay_download_path(@session), class: "btn btn-primary" %> <%= link_to t("replay.show.download_link"), public_replay_download_path(@session), class: "btn btn-primary" %>
<% end %> <% end %>
<% if @recording.youtube_watch_url %> <% if @recording.youtube_watch_url %>
@@ -53,25 +80,6 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% elsif @recording.ready? && @recording.youtube_watch_url.present? %>
<div class="live-player-wrap live-player-wrap--embed">
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
title="<%= @recording.title_or_default %>"
class="live-player-embed"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
<%= render "public/live/player_overlays",
match: @match,
session: @session,
stream_closed: true,
on_air: false,
badge_label: t("replay.show.badge_label"),
badge_class: "badge-ended" %>
</div>
<p class="replay-show__meta-line">
<%= t("replay.show.youtube_only_body") %>
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
</p>
<% elsif @recording.ready? %> <% elsif @recording.ready? %>
<div class="stream-ended" role="status"> <div class="stream-ended" role="status">
<h2><%= t("replay.show.file_missing_title") %></h2> <h2><%= t("replay.show.file_missing_title") %></h2>
@@ -158,16 +158,16 @@
<% end %> <% end %>
<span class="visually-hidden"><%= privacy_label %></span> <span class="visually-hidden"><%= privacy_label %></span>
<% end %> <% end %>
<% if ent.phone_download_enabled? && rec.ready? %> <% if ent.phone_download_enabled? && rec.ready? && rec.storage_key.present? %>
<%= link_to "MP4", public_replay_download_path(rec.stream_session_id), class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.download_mp4_title") %> <%= link_to "MP4", public_replay_download_path(rec.stream_session_id), class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.download_mp4_title") %>
<% end %> <% end %>
<% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.youtube_video_id.blank? %> <% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.storage_key.present? && rec.youtube_video_id.blank? %>
<%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.publish_youtube_title"), form: { class: "replay-archive__action-form" } %> <%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.publish_youtube_title"), form: { class: "replay-archive__action-form" } %>
<% elsif rec.youtube_watch_url %> <% elsif rec.youtube_watch_url %>
<%= link_to "YT", rec.youtube_watch_url, class: "replay-archive__action replay-archive__action--secondary", target: "_blank", rel: "noopener", title: t("recordings.archive.open_youtube_title") %> <%= link_to "YT", rec.youtube_watch_url, class: "replay-archive__action replay-archive__action--secondary", target: "_blank", rel: "noopener", title: t("recordings.archive.open_youtube_title") %>
<% end %> <% end %>
<% has_youtube = rec.youtube_video_id.present? && !rec.youtube_video_id.to_s.start_with?("mock_") %> <% has_youtube = rec.youtube_video_id.present? && !rec.youtube_video_id.to_s.start_with?("mock_") %>
<% has_site = rec.storage_key.present? || %w[ready processing failed].include?(rec.status) %> <% has_site = rec.available_in_archive? || %w[processing failed].include?(rec.status) %>
<% delete_confirm = t("recordings.archive.delete_confirm") %> <% delete_confirm = t("recordings.archive.delete_confirm") %>
<%= button_to paths.destroy.call(rec), method: :delete, <%= button_to paths.destroy.call(rec), method: :delete,
params: filter_params, params: filter_params,
@@ -1,10 +1,9 @@
<footer class="site-footer"> <footer class="site-footer">
<div class="wrap"> <div class="wrap">
<div> <div class="site-footer__brand">
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %> <strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
<%= render "shared/store_badges", variant: "footer" %>
</div> </div>
<div> <div class="site-footer__nav">
<%= link_to t("common.contacts"), public_contatti_path %> · <%= link_to t("common.contacts"), public_contatti_path %> ·
<%= link_to t("common.support"), public_support_path %> · <%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> · <%= link_to t("common.pricing"), public_prezzi_path %> ·
@@ -15,6 +14,10 @@
<%= link_to t("common.terms"), public_termini_path %> <%= link_to t("common.terms"), public_termini_path %>
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button> · <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
</div> </div>
<div class="site-footer__apps">
<%= render "shared/store_badges", variant: "footer" %>
<%= render "shared/social_links", modifier: "footer" %>
</div>
<div class="site-footer__legal"> <div class="site-footer__legal">
<p><%= t("footer.copyright") %></p> <p><%= t("footer.copyright") %></p>
<p><%= t("footer.responsibility") %></p> <p><%= t("footer.responsibility") %></p>
@@ -1,16 +1,19 @@
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %> <%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
<footer class="site-footer"> <footer class="site-footer">
<div class="wrap"> <div class="wrap">
<div> <div class="site-footer__brand">
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %> <strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
</div> </div>
<div> <div class="site-footer__nav">
<%= link_to t("common.support"), public_support_path %> · <%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.privacy"), public_privacy_path %> · <%= link_to t("common.privacy"), public_privacy_path %> ·
<%= link_to t("common.cookies"), public_cookies_path %> · <%= link_to t("common.cookies"), public_cookies_path %> ·
<%= link_to t("common.terms"), public_termini_path %> <%= link_to t("common.terms"), public_termini_path %>
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button> · <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
</div> </div>
<div class="site-footer__apps">
<%= render "shared/social_links", modifier: "footer" %>
</div>
<div class="site-footer__legal"> <div class="site-footer__legal">
<p><%= t("footer.copyright") %></p> <p><%= t("footer.copyright") %></p>
<p><%= t("footer.responsibility") %></p> <p><%= t("footer.responsibility") %></p>
@@ -0,0 +1,41 @@
<%# Social links: Facebook, Instagram, TikTok, YouTube — glyph brand-color su sfondo trasparente. %>
<% urls = {
facebook: "https://www.facebook.com/matchlivetvapp",
instagram: "https://www.instagram.com/matchlivetv_app/",
tiktok: "https://www.tiktok.com/@matchlivetv_app",
youtube: "https://www.youtube.com/@SportMatchLiveTv"
} %>
<% ig_grad_id = "ig-grad-#{SecureRandom.hex(4)}" %>
<nav class="site-social<%= local_assigns[:modifier].present? ? " site-social--#{modifier}" : "" %>" aria-label="<%= t("footer.social_nav") %>">
<a class="site-social__link site-social__link--facebook" href="<%= urls[:facebook] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_facebook") %>">
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M14 8.2h2.2V5h-2.2C11.7 5 10 6.8 10 9.2V11H8v3.2h2V19h3.2v-4.8h2.2l.6-3.2h-2.8V9.2c0-.6.4-1 1-1z"/>
</svg>
</a>
<a class="site-social__link site-social__link--instagram" href="<%= urls[:instagram] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_instagram") %>">
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
<defs>
<linearGradient id="<%= ig_grad_id %>" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f58529"/>
<stop offset="45%" stop-color="#dd2a7b"/>
<stop offset="100%" stop-color="#515bd4"/>
</linearGradient>
</defs>
<path fill="url(#<%= ig_grad_id %>)" d="M12 7.2A4.8 4.8 0 1 0 12 16.8 4.8 4.8 0 0 0 12 7.2zm0 7.7a2.9 2.9 0 1 1 0-5.8 2.9 2.9 0 0 1 0 5.8z"/>
<circle fill="url(#<%= ig_grad_id %>)" cx="17.4" cy="6.7" r="1.1"/>
<path fill="url(#<%= ig_grad_id %>)" d="M12 2.5c-2.6 0-2.9 0-3.9.1-2.6.1-3.9 1.4-4 4-.1 1-.1 1.3-.1 3.9s0 2.9.1 3.9c.1 2.6 1.4 3.9 4 4 1 .1 1.3.1 3.9.1s2.9 0 3.9-.1c2.6-.1 3.9-1.4 4-4 .1-1 .1-1.3.1-3.9s0-2.9-.1-3.9c-.1-2.6-1.4-3.9-4-4-1-.1-1.3-.1-3.9-.1zm0 1.7c2.5 0 2.8 0 3.8.1 1.8.1 2.7.9 2.8 2.8.1 1 .1 1.3.1 3.8s0 2.8-.1 3.8c-.1 1.8-.9 2.7-2.8 2.8-1 .1-1.3.1-3.8.1s-2.8 0-3.8-.1c-1.8-.1-2.7-.9-2.8-2.8-.1-1-.1-1.3-.1-3.8s0-2.8.1-3.8c.1-1.8 1-2.7 2.8-2.8 1-.1 1.3-.1 3.8-.1z"/>
</svg>
</a>
<a class="site-social__link site-social__link--tiktok" href="<%= urls[:tiktok] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_tiktok") %>">
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
<path fill="#69C9D0" transform="translate(1.15 0.75)" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
<path fill="#EE1D52" transform="translate(-1.15 -0.75)" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
<path fill="#fff" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
</svg>
</a>
<a class="site-social__link site-social__link--youtube" href="<%= urls[:youtube] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_youtube") %>">
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
<path fill="currentColor" d="M23.5 7.2a3 3 0 0 0-2.1-2.1C19.5 4.6 12 4.6 12 4.6s-7.5 0-9.4.5A3 3 0 0 0 .5 7.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-4.8zM9.6 15.5v-7l6.3 3.5-6.3 3.5z"/>
</svg>
</a>
</nav>
@@ -196,6 +196,24 @@ module MatchLiveTv
ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true" ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true"
end end
# Retention copia temporanea post-live YouTube (ore). Clamp 2472, default 48.
def youtube_temp_replay_retention_hours
raw = ENV.fetch("YOUTUBE_TEMP_REPLAY_RETENTION_HOURS", "48").to_i
raw.clamp(24, 72)
end
def youtube_replay_verify_max_attempts
ENV.fetch("YOUTUBE_REPLAY_VERIFY_MAX_ATTEMPTS", "12").to_i
end
def youtube_replay_verify_base_interval_secs
ENV.fetch("YOUTUBE_REPLAY_VERIFY_BASE_INTERVAL_SECS", "300").to_i
end
def youtube_replay_verify_grace_secs
ENV.fetch("YOUTUBE_REPLAY_VERIFY_GRACE_SECS", "120").to_i
end
def ops_http_rails_url def ops_http_rails_url
ENV.fetch("OPS_HTTP_RAILS_URL", "http://edge/up") ENV.fetch("OPS_HTTP_RAILS_URL", "http://edge/up")
end end
+8
View File
@@ -111,6 +111,7 @@ de:
table: table:
match: Spiel match: Spiel
status: Status status: Status
client: Client
ingest: Ingest ingest: Ingest
start: Start start: Start
link: Link link: Link
@@ -276,6 +277,7 @@ de:
duration: Dauer duration: Dauer
ingest: Ingest ingest: Ingest
disconnects: Verbindungsabbrüche disconnects: Verbindungsabbrüche
client: Client
link: Link link: Link
detail: Details detail: Details
regia: Regie regia: Regie
@@ -308,6 +310,11 @@ de:
opponent: Gegner opponent: Gegner
operator: Operator operator: Operator
platform: Plattform platform: Plattform
client_os: System
app_version: App-Version
device: Gerät
os_version: OS-Version
carrier: Mobilfunkanbieter
privacy: Privacy privacy: Privacy
quality: Qualität quality: Qualität
min_quality: Min. Qualität min_quality: Min. Qualität
@@ -346,6 +353,7 @@ de:
generate_button: Regie-Link erzeugen generate_button: Regie-Link erzeugen
ingest: ingest:
none: "—" none: "—"
decommissioned: "(Knoten entfernt)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
+8
View File
@@ -111,6 +111,7 @@ en:
table: table:
match: Match match: Match
status: Status status: Status
client: Client
ingest: Ingest ingest: Ingest
start: Start start: Start
link: Link link: Link
@@ -276,6 +277,7 @@ en:
duration: Duration duration: Duration
ingest: Ingest ingest: Ingest
disconnects: Disconnects disconnects: Disconnects
client: Client
link: Link link: Link
detail: Details detail: Details
regia: Control regia: Control
@@ -308,6 +310,11 @@ en:
opponent: Opponent opponent: Opponent
operator: Operator operator: Operator
platform: Platform platform: Platform
client_os: OS
app_version: App version
device: Device
os_version: OS version
carrier: Carrier
privacy: Privacy privacy: Privacy
quality: Quality quality: Quality
min_quality: Min quality min_quality: Min quality
@@ -346,6 +353,7 @@ en:
generate_button: Generate control link generate_button: Generate control link
ingest: ingest:
none: "—" none: "—"
decommissioned: "(node removed)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
+8
View File
@@ -111,6 +111,7 @@ es:
table: table:
match: Partido match: Partido
status: Estado status: Estado
client: Cliente
ingest: Ingest ingest: Ingest
start: Inicio start: Inicio
link: Enlace link: Enlace
@@ -276,6 +277,7 @@ es:
duration: Duración duration: Duración
ingest: Ingest ingest: Ingest
disconnects: Desconexiones disconnects: Desconexiones
client: Cliente
link: Enlace link: Enlace
detail: Detalle detail: Detalle
regia: Regie regia: Regie
@@ -308,6 +310,11 @@ es:
opponent: Rival opponent: Rival
operator: Operador operator: Operador
platform: Plataforma platform: Plataforma
client_os: Sistema
app_version: Versión app
device: Dispositivo
os_version: Versión OS
carrier: Operador móvil
privacy: Privacidad privacy: Privacidad
quality: Calidad quality: Calidad
min_quality: Calidad mínima min_quality: Calidad mínima
@@ -346,6 +353,7 @@ es:
generate_button: Generar enlace de regie generate_button: Generar enlace de regie
ingest: ingest:
none: "—" none: "—"
decommissioned: "(nodo eliminado)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
+8
View File
@@ -111,6 +111,7 @@ fr:
table: table:
match: Match match: Match
status: Statut status: Statut
client: Client
ingest: Ingest ingest: Ingest
start: Début start: Début
link: Lien link: Lien
@@ -276,6 +277,7 @@ fr:
duration: Durée duration: Durée
ingest: Ingest ingest: Ingest
disconnects: Déconnexions disconnects: Déconnexions
client: Client
link: Lien link: Lien
detail: Détail detail: Détail
regia: Régie regia: Régie
@@ -308,6 +310,11 @@ fr:
opponent: Adversaire opponent: Adversaire
operator: Opérateur operator: Opérateur
platform: Plateforme platform: Plateforme
client_os: Système
app_version: Version app
device: Appareil
os_version: Version OS
carrier: Opérateur mobile
privacy: Confidentialité privacy: Confidentialité
quality: Qualité quality: Qualité
min_quality: Qualité mini min_quality: Qualité mini
@@ -346,6 +353,7 @@ fr:
generate_button: Générer le lien de régie generate_button: Générer le lien de régie
ingest: ingest:
none: "—" none: "—"
decommissioned: "(nœud retiré)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
+8
View File
@@ -115,6 +115,7 @@ it:
table: table:
match: Partita match: Partita
status: Stato status: Stato
client: Client
ingest: Ingest ingest: Ingest
start: Inizio start: Inizio
link: Link link: Link
@@ -297,6 +298,7 @@ it:
duration: Durata duration: Durata
ingest: Ingest ingest: Ingest
disconnects: Disconnessioni disconnects: Disconnessioni
client: Client
link: Link link: Link
detail: Dettaglio detail: Dettaglio
regia: Regia regia: Regia
@@ -329,6 +331,11 @@ it:
opponent: Avversario opponent: Avversario
operator: Operatore operator: Operatore
platform: Piattaforma platform: Piattaforma
client_os: Sistema
app_version: Versione app
device: Dispositivo
os_version: Versione OS
carrier: Operatore telefonico
privacy: Privacy privacy: Privacy
quality: Qualità quality: Qualità
min_quality: Qualità minima min_quality: Qualità minima
@@ -367,6 +374,7 @@ it:
generate_button: Genera link regia generate_button: Genera link regia
ingest: ingest:
none: "—" none: "—"
decommissioned: "(nodo rimosso)"
role: role:
home: Home-lab home: Home-lab
lab: Lab lab: Lab
+1
View File
@@ -192,6 +192,7 @@ de:
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
company_title: Sitz company_title: Sitz
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung. company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
social_title: Folgen Sie uns auch in den sozialen Medien
label_address: Adresse label_address: Adresse
label_vat: USt-IdNr. label_vat: USt-IdNr.
form_title: Nachricht senden form_title: Nachricht senden
+1
View File
@@ -192,6 +192,7 @@ en:
privacy_body: GDPR rights, consent and personal data. Write to privacy_body: GDPR rights, consent and personal data. Write to
company_title: Company details company_title: Company details
company_lead: Service provider and data controller. company_lead: Service provider and data controller.
social_title: Follow us on social media
label_address: Address label_address: Address
label_vat: VAT number label_vat: VAT number
form_title: Send a message form_title: Send a message
+1
View File
@@ -192,6 +192,7 @@ es:
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
company_title: Sede company_title: Sede
company_lead: Prestador del servicio y responsable del tratamiento. company_lead: Prestador del servicio y responsable del tratamiento.
social_title: Síguenos también en redes sociales
label_address: Dirección label_address: Dirección
label_vat: NIF / IVA label_vat: NIF / IVA
form_title: Enviar un mensaje form_title: Enviar un mensaje
+1
View File
@@ -192,6 +192,7 @@ fr:
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
company_title: Siège company_title: Siège
company_lead: Prestataire du service et responsable du traitement. company_lead: Prestataire du service et responsable du traitement.
social_title: Suivez-nous aussi sur les réseaux sociaux
label_address: Adresse label_address: Adresse
label_vat: N° de TVA label_vat: N° de TVA
form_title: Envoyer un message form_title: Envoyer un message
+1
View File
@@ -192,6 +192,7 @@ it:
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
company_title: Sede company_title: Sede
company_lead: Titolare del servizio e del trattamento dei dati. company_lead: Titolare del servizio e del trattamento dei dati.
social_title: Seguici anche sui social
label_address: Indirizzo label_address: Indirizzo
label_vat: Partita IVA label_vat: Partita IVA
form_title: Invia un messaggio form_title: Invia un messaggio
+5
View File
@@ -24,6 +24,11 @@ de:
manage_cookies: Cookies verwalten manage_cookies: Cookies verwalten
copyright: "© 2026 Emiliano Frascaro USt-IdNr. 14230270960" copyright: "© 2026 Emiliano Frascaro USt-IdNr. 14230270960"
responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen. responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen.
social_nav: MatchLiveTV Social-Profile
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookies und Datenschutz title: Cookies und Datenschutz
body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}. body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}.
+5
View File
@@ -24,6 +24,11 @@ en:
manage_cookies: Manage cookies manage_cookies: Manage cookies
copyright: "© 2026 Emiliano Frascaro VAT 14230270960" copyright: "© 2026 Emiliano Frascaro VAT 14230270960"
responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it. responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it.
social_nav: MatchLiveTV social profiles
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookies and privacy title: Cookies and privacy
body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}. body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}.
+5
View File
@@ -24,6 +24,11 @@ es:
manage_cookies: Gestionar cookies manage_cookies: Gestionar cookies
copyright: "© 2026 Emiliano Frascaro NIF 14230270960" copyright: "© 2026 Emiliano Frascaro NIF 14230270960"
responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican. responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican.
social_nav: Perfiles sociales MatchLiveTV
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookies y privacidad title: Cookies y privacidad
body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}. body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}.
+5
View File
@@ -24,6 +24,11 @@ fr:
manage_cookies: Gérer les cookies manage_cookies: Gérer les cookies
copyright: "© 2026 Emiliano Frascaro TVA 14230270960" copyright: "© 2026 Emiliano Frascaro TVA 14230270960"
responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient. responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient.
social_nav: Profils sociaux MatchLiveTV
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookies et confidentialité title: Cookies et confidentialité
body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}. body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}.
+5
View File
@@ -24,6 +24,11 @@ it:
manage_cookies: Gestisci cookie manage_cookies: Gestisci cookie
copyright: "© 2026 Emiliano Frascaro P. IVA 14230270960" copyright: "© 2026 Emiliano Frascaro P. IVA 14230270960"
responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano. responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.
social_nav: Profili social MatchLiveTV
social_facebook: Facebook MatchLiveTV
social_instagram: Instagram MatchLiveTV
social_tiktok: TikTok MatchLiveTV
social_youtube: YouTube MatchLiveTV
cookie: cookie:
title: Cookie e privacy title: Cookie e privacy
body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}. body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}.
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddClientTelemetryToStreamSessions < ActiveRecord::Migration[7.2]
def change
change_table :stream_sessions, bulk: true do |t|
t.string :client_os
t.string :app_version
t.string :app_build
t.string :device_manufacturer
t.string :device_model
t.string :os_version
t.string :carrier
end
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddTemporaryReplayFieldsToRecordings < ActiveRecord::Migration[7.2]
def change
change_table :recordings, bulk: true do |t|
t.string :storage_policy, null: false, default: "retained"
t.datetime :temp_expires_at
t.datetime :youtube_verified_at
t.datetime :local_media_purged_at
end
add_index :recordings, %i[storage_policy temp_expires_at],
name: "index_recordings_on_storage_policy_and_temp_expires_at"
end
end
@@ -0,0 +1,51 @@
# frozen_string_literal: true
class AddIngestSnapshotToStreamSessions < ActiveRecord::Migration[7.2]
def up
add_column :stream_sessions, :ingest_slug, :string
add_column :stream_sessions, :ingest_role, :string
add_index :stream_sessions, :ingest_slug
execute <<~SQL
UPDATE stream_sessions ss
SET ingest_slug = sn.slug,
ingest_role = sn.role
FROM stream_nodes sn
WHERE ss.stream_node_id = sn.id
AND ss.ingest_slug IS NULL
SQL
execute <<~SQL
UPDATE stream_sessions ss
SET ingest_slug = ev.slug
FROM (
SELECT DISTINCT ON (stream_session_id)
stream_session_id,
metadata->>'stream_node' AS slug
FROM stream_events
WHERE event_type = 'pairing'
AND COALESCE(metadata->>'stream_node', '') <> ''
ORDER BY stream_session_id, occurred_at ASC
) ev
WHERE ss.id = ev.stream_session_id
AND ss.ingest_slug IS NULL
SQL
execute <<~SQL
UPDATE stream_sessions
SET ingest_role = CASE
WHEN ingest_slug = 'home' THEN 'home'
WHEN ingest_slug LIKE 'ingest-lab%' THEN 'lab'
WHEN ingest_slug LIKE 'ingest-%' THEN 'cloud'
ELSE ingest_role
END
WHERE ingest_slug IS NOT NULL AND ingest_role IS NULL
SQL
end
def down
remove_index :stream_sessions, :ingest_slug
remove_column :stream_sessions, :ingest_role
remove_column :stream_sessions, :ingest_slug
end
end
+16 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -338,10 +338,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
t.datetime "expiry_warning_sent_at" t.datetime "expiry_warning_sent_at"
t.string "youtube_video_id" t.string "youtube_video_id"
t.datetime "youtube_published_at" t.datetime "youtube_published_at"
t.string "storage_policy", default: "retained", null: false
t.datetime "temp_expires_at"
t.datetime "youtube_verified_at"
t.datetime "local_media_purged_at"
t.index ["deleted_at"], name: "index_recordings_on_deleted_at" t.index ["deleted_at"], name: "index_recordings_on_deleted_at"
t.index ["expires_at"], name: "index_recordings_on_expires_at" t.index ["expires_at"], name: "index_recordings_on_expires_at"
t.index ["privacy_status"], name: "index_recordings_on_privacy_status" t.index ["privacy_status"], name: "index_recordings_on_privacy_status"
t.index ["storage_key"], name: "index_recordings_on_storage_key", unique: true, where: "(storage_key IS NOT NULL)" t.index ["storage_key"], name: "index_recordings_on_storage_key", unique: true, where: "(storage_key IS NOT NULL)"
t.index ["storage_policy", "temp_expires_at"], name: "index_recordings_on_storage_policy_and_temp_expires_at"
t.index ["stream_session_id"], name: "index_recordings_on_stream_session_id", unique: true t.index ["stream_session_id"], name: "index_recordings_on_stream_session_id", unique: true
t.index ["team_id", "status"], name: "index_recordings_on_team_id_and_status" t.index ["team_id", "status"], name: "index_recordings_on_team_id_and_status"
t.index ["team_id"], name: "index_recordings_on_team_id" t.index ["team_id"], name: "index_recordings_on_team_id"
@@ -427,6 +432,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
t.uuid "stream_node_id" t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", null: false t.string "min_quality_preset", default: "auto", null: false
t.boolean "audio_muted", default: false, null: false t.boolean "audio_muted", default: false, null: false
t.string "client_os"
t.string "app_version"
t.string "app_build"
t.string "device_manufacturer"
t.string "device_model"
t.string "os_version"
t.string "carrier"
t.string "ingest_slug"
t.string "ingest_role"
t.index ["ingest_slug"], name: "index_stream_sessions_on_ingest_slug"
t.index ["match_id"], name: "index_stream_sessions_on_match_id" t.index ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
+33
View File
@@ -20,4 +20,37 @@ namespace :recordings do
orphan = Mediamtx::CleanupOrphanPaths.new.call orphan = Mediamtx::CleanupOrphanPaths.new.call
puts "Path MediaMTX orfani rimossi: #{orphan.removed} (saltati: #{orphan.skipped})" puts "Path MediaMTX orfani rimossi: #{orphan.removed} (saltati: #{orphan.skipped})"
end end
desc "Elimina copie temporanee YouTube scadute (solo storage, non soft-delete)"
task purge_temporary: :environment do
count = Recordings::PurgeTemporaryMediaJob.new.perform
puts "Purge temporary replays: processed=#{count}"
end
desc "Audit dry-run: recording YouTube con ancora MP4 in Garage (duplicati storici). " \
"Cleanup solo se APPLY=1 (non implementato di default — solo report)."
task audit_youtube_duplicates: :environment do
apply = ENV["APPLY"].to_s == "1"
scope = Recording.not_deleted
.where.not(youtube_video_id: [nil, ""])
.where.not(storage_key: [nil, ""])
.where("youtube_video_id NOT LIKE 'mock_%'")
puts "Audit YouTube duplicates (dry-run=#{!apply}): count=#{scope.count}"
scope.find_each do |rec|
puts [
"id=#{rec.id}",
"policy=#{rec.storage_policy}",
"key=#{rec.storage_key}",
"yt=#{rec.youtube_video_id}",
"purged_at=#{rec.local_media_purged_at.inspect}",
"temp_expires=#{rec.temp_expires_at.inspect}"
].join(" ")
end
if apply
puts "APPLY=1 richiesto ma cleanup storico automatico NON eseguito " \
"(fuori scope; solo dry-run supportato)."
end
end
end end
+86 -2
View File
@@ -1307,7 +1307,7 @@ body.nav-menu-open { overflow: hidden; }
letter-spacing: -0.01em; letter-spacing: -0.01em;
} }
.store-badges--footer { .store-badges--footer {
margin-top: 12px; margin-top: 0;
} }
.store-badges--footer .store-badge { .store-badges--footer .store-badge {
min-height: 42px; min-height: 42px;
@@ -2459,10 +2459,94 @@ body.nav-menu-open { overflow: hidden; }
} }
.stripe-secure--compact i { font-size: 0.95rem; color: #888; } .stripe-secure--compact i { font-size: 0.95rem; color: #888; }
.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; } .site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; }
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; } .site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; }
.site-footer__brand {
flex: 1 1 auto;
min-width: 0;
}
.site-footer__nav {
flex: 0 1 auto;
text-align: right;
}
.site-footer__apps {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 20px;
width: 100%;
flex: 1 1 100%;
}
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; } .site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; } .site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
.site-footer__legal p:last-child { margin-bottom: 0; } .site-footer__legal p:last-child { margin-bottom: 0; }
.site-social {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.site-social__link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 8px;
background: transparent;
border: none;
text-decoration: none;
opacity: 0.95;
transition: opacity 0.18s ease, transform 0.18s ease;
}
.site-social__link:hover,
.site-social__link:focus-visible {
opacity: 1;
transform: translateY(-1px);
outline: none;
}
.site-social__link--facebook {
color: #1877F2;
}
.site-social__link--instagram {
color: #dd2a7b;
}
.site-social__link--youtube {
color: #FF0000;
}
.site-social__icon {
display: block;
width: 36px;
height: 36px;
}
.site-social--footer {
margin-top: 0;
margin-left: auto;
justify-content: flex-end;
}
.site-social--panel {
margin-top: 4px;
justify-content: center;
gap: 16px;
}
.site-social--panel .site-social__link {
width: 64px;
height: 64px;
}
.site-social--panel .site-social__icon {
width: 44px;
height: 44px;
}
.contacts-company,
.contacts-social {
max-width: 640px;
margin-left: auto;
margin-right: auto;
text-align: center;
}
.contacts-social h2 {
margin: 0 0 14px;
}
.compare-table-wrap { .compare-table-wrap {
margin-top: 28px; margin-top: 28px;
} }
@@ -34,4 +34,35 @@ RSpec.describe StreamSession do
expect(session.mediamtx_api_base_url).to eq("http://10.0.0.2:9997") expect(session.mediamtx_api_base_url).to eq("http://10.0.0.2:9997")
expect(session.mediamtx_internal_rtmp_url).to eq("rtmp://10.0.0.2:1935") expect(session.mediamtx_internal_rtmp_url).to eq("rtmp://10.0.0.2:1935")
end end
it "keeps ingest snapshot after the stream node is destroyed" do
node = StreamNode.create!(
slug: "ingest-01",
hostname: "ingest-01.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-01.mltv-stream.net:1935",
hls_base_url: "https://ingest-01.mltv-stream.net/hls",
api_base_url: "http://10.0.0.2:9997",
internal_rtmp_url: "rtmp://10.0.0.2:1935",
max_publishers: 4,
max_relays: 4
)
user = User.create!(email: "snap@example.com", name: "S", password: "Password123", role: "coach")
club = Club.create!(name: "Club Snap", sport: "volleyball")
team = club.teams.create!(name: "Team", sport: "volleyball", slug: "team-snap")
match = team.matches.create!(opponent_name: "Opp", scheduled_at: 1.hour.from_now)
session = StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "ended", stream_node: node
)
expect(session.ingest_slug).to eq("ingest-01")
expect(session.ingest_role).to eq("cloud")
node.destroy!
session.reload
expect(session.stream_node).to be_nil
expect(session.ingest_slug_display).to eq("ingest-01")
expect(session.ingest_role_display).to eq("cloud")
end
end end
@@ -60,4 +60,31 @@ RSpec.describe "Admin sessions index", type: :request do
expect(response.body).to include("Dettaglio sessione").or include("Session details") expect(response.body).to include("Dettaglio sessione").or include("Session details")
expect(response.body).to include(session_a.id) expect(response.body).to include(session_a.id)
end end
it "mostra lo snapshot ingest anche se il nodo è stato decommissionato" do
node = StreamNode.create!(
slug: "ingest-09",
hostname: "ingest-09.mltv-stream.net",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-09.mltv-stream.net:1935",
hls_base_url: "https://collaudo.example/hls",
api_base_url: "http://10.0.0.9:9997",
max_publishers: 4,
max_relays: 4
)
session_a.update!(stream_node: node)
node.destroy!
session_a.reload
get admin_sessions_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("ingest-09")
expect(response.body).to include("Hetzner")
get admin_session_path(session_a)
expect(response.body).to include("ingest-09")
expect(response.body).to include("nodo rimosso").or include("node removed")
end
end end
@@ -0,0 +1,60 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Public replay show youtube-only", type: :request do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "pub-replay@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.ago) }
let!(:session) do
StreamSession.create!(
match: match, user: user, platform: "youtube", status: "ended", ended_at: 1.hour.ago,
youtube_broadcast_id: "bcast"
)
end
it "mostra embed YouTube senza storage_key" do
Recording.create!(
stream_session: session,
team: team,
status: "ready",
storage_policy: "temporary",
storage_key: nil,
youtube_video_id: "publicYtVid01",
youtube_verified_at: Time.current,
local_media_purged_at: Time.current,
privacy_status: "public",
title: "Derby",
expires_at: nil,
metadata: { "source_platform" => "youtube" }
)
get "/replay/#{session.id}"
expect(response).to have_http_status(:ok)
expect(response.body).to include("youtube.com/embed/publicYtVid01")
expect(response.body).not_to include("id=\"replay-player\"")
end
it "mostra player MP4 per retained con storage_key" do
mltv = StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago
)
Recording.create!(
stream_session: mltv,
team: team,
status: "ready",
storage_policy: "retained",
storage_key: "teams/#{team.id}/sessions/#{mltv.id}/replay.mp4",
privacy_status: "public",
title: "Casa",
expires_at: 30.days.from_now,
metadata: { "source_platform" => "matchlivetv" }
)
get "/replay/#{mltv.id}"
expect(response).to have_http_status(:ok)
expect(response.body).to include("id=\"replay-player\"")
expect(response.body).not_to include("youtube.com/embed/")
end
end
@@ -0,0 +1,52 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "API session create ingest unavailable", type: :request do
let!(:user) do
User.create!(email: "ingest-#{SecureRandom.hex(4)}@example.com", name: "Coach", password: "Password123", role: "coach")
end
let!(:club) { Club.create!(name: "IngestClub", sport: "volleyball") }
let!(:membership) { club.club_memberships.create!(user: user, role: "owner") }
let!(:team) { club.teams.create!(name: "Tigers", sport: "volleyball", slug: "ingest-tigers-#{SecureRandom.hex(3)}") }
let!(:match) { team.matches.create!(opponent_name: "Opp", scheduled_at: 1.hour.from_now) }
def auth_headers
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
token = response.parsed_body["access_token"]
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
end
before do
plan = Plan.find_or_initialize_by(slug: "premium_full")
plan.name ||= "Premium Full"
plan.features = (plan.features || {}).merge(
"platforms" => %w[matchlivetv youtube],
"youtube_enabled" => true,
"concurrent_streams_limit" => 10,
"recordings_enabled" => true,
"recording_days" => 90
)
plan.save!
club.create_subscription!(plan: plan, status: "active") if club.subscription.blank?
allow_any_instance_of(Teams::Entitlements).to receive(:assert_can_stream_on!)
allow_any_instance_of(Teams::Entitlements).to receive(:assert_concurrent_stream!)
allow(Streams::CoverSlateEnsurer).to receive(:ensure_for!)
allow(Streams::SlateDistributor).to receive(:ensure_for!)
Streams::NodeRegistry.ensure_home_from_env!
end
it "returns 503 when MediaMTX connection fails after retries" do
allow_any_instance_of(Mediamtx::Client).to receive(:create_path)
.and_raise(Faraday::ConnectionFailed.new("Connection refused"))
post "/api/v1/matches/#{match.id}/sessions",
params: { platform: "matchlivetv", privacy_status: "private" }.to_json,
headers: auth_headers
expect(response).to have_http_status(:service_unavailable)
body = response.parsed_body
expect(body["error_code"]).to eq("stream_ingest_unavailable")
end
end
@@ -0,0 +1,94 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Api::V1 team recordings replay_source", type: :request do
let!(:user) { User.create!(email: "rec-api@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:owner) { club.club_memberships.create!(user: user, role: "owner") }
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", scheduled_at: 1.day.ago) }
def auth_headers
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
token = response.parsed_body["access_token"]
{ "Authorization" => "Bearer #{token}" }
end
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
end
it "youtube-only: replay_source youtube, no playback/download" do
session = StreamSession.create!(
match: match, user: user, platform: "youtube", status: "ended", ended_at: 1.hour.ago,
youtube_broadcast_id: "bcast1"
)
Recording.create!(
stream_session: session,
team: team,
status: "ready",
storage_policy: "temporary",
storage_key: nil,
youtube_video_id: "ytVideoId123",
youtube_verified_at: Time.current,
local_media_purged_at: Time.current,
privacy_status: "public",
expires_at: nil,
metadata: { "source_platform" => "youtube" }
)
get "/api/v1/teams/#{team.id}/recordings", headers: auth_headers
expect(response).to have_http_status(:ok)
item = response.parsed_body.find { |r| r["session_id"] == session.id } ||
response.parsed_body.find { |r| r["youtube_video_id"] == "ytVideoId123" }
expect(item).to be_present
expect(item["replay_source"]).to eq("youtube")
expect(item["youtube_watch_url"]).to include("ytVideoId123")
expect(item["playback_url"]).to be_nil
expect(item["download_enabled"]).to eq(false)
end
it "matchlivetv retained: replay_source matchlivetv with playback" do
session = StreamSession.create!(
match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago
)
Recording.create!(
stream_session: session,
team: team,
status: "ready",
storage_policy: "retained",
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4",
privacy_status: "public",
expires_at: 30.days.from_now,
metadata: { "source_platform" => "matchlivetv" }
)
get "/api/v1/teams/#{team.id}/recordings", headers: auth_headers
expect(response).to have_http_status(:ok)
item = response.parsed_body.find { |r| r["session_id"] == session.id }
expect(item["replay_source"]).to eq("matchlivetv")
expect(item["playback_url"]).to be_present
expect(item["download_enabled"]).to eq(true)
end
it "download API rejects youtube-only recording" do
session = StreamSession.create!(
match: match, user: user, platform: "youtube", status: "ended", ended_at: 1.hour.ago
)
recording = Recording.create!(
stream_session: session,
team: team,
status: "ready",
storage_policy: "temporary",
storage_key: nil,
youtube_video_id: "ytVideoId999",
youtube_verified_at: Time.current,
privacy_status: "public"
)
get "/api/v1/recordings/#{recording.id}/download", headers: auth_headers
expect(response).to have_http_status(:unprocessable_entity)
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Mediamtx::Client do
describe "#create_path" do
let(:session) do
user = User.create!(email: "mtx-#{SecureRandom.hex(4)}@example.com", name: "M", password: "Password123", role: "coach")
club = Club.create!(name: "MtxClub-#{SecureRandom.hex(3)}", sport: "volleyball")
team = club.teams.create!(name: "T", sport: "volleyball", slug: "mtx-#{SecureRandom.hex(4)}")
match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now)
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle")
end
it "retries Faraday connection failures then succeeds" do
ENV["MEDIAMTX_CREATE_RETRIES"] = "3"
ENV["MEDIAMTX_CREATE_RETRY_BASE_SECS"] = "0"
client = described_class.new(base_url: "http://mtx.test:9997")
conn = instance_double(Faraday::Connection)
client.instance_variable_set(:@conn, conn)
fail_once = Faraday::ConnectionFailed.new("Connection refused")
ok = instance_double(Faraday::Response, success?: true, status: 200, body: {})
expect(conn).to receive(:post).once.and_raise(fail_once)
expect(conn).to receive(:post).once.and_return(ok)
expect(client.create_path(session)).to eq(true)
ensure
ENV.delete("MEDIAMTX_CREATE_RETRIES")
ENV.delete("MEDIAMTX_CREATE_RETRY_BASE_SECS")
end
it "raises after exhausting connection retries" do
ENV["MEDIAMTX_CREATE_RETRIES"] = "2"
ENV["MEDIAMTX_CREATE_RETRY_BASE_SECS"] = "0"
client = described_class.new(base_url: "http://mtx.test:9997")
conn = instance_double(Faraday::Connection)
client.instance_variable_set(:@conn, conn)
allow(conn).to receive(:post).and_raise(Faraday::ConnectionFailed.new("Connection refused"))
expect { client.create_path(session) }.to raise_error(Faraday::ConnectionFailed)
ensure
ENV.delete("MEDIAMTX_CREATE_RETRIES")
ENV.delete("MEDIAMTX_CREATE_RETRY_BASE_SECS")
end
end
end
@@ -25,7 +25,9 @@ RSpec.describe Recordings::FinalizeSession do
rec = described_class.new(session).call rec = described_class.new(session).call
expect(rec.status).to eq("processing") expect(rec.status).to eq("processing")
expect(rec.privacy_status).to eq("public") expect(rec.privacy_status).to eq("public")
expect(rec.storage_policy).to eq("retained")
expect(rec.expires_at).to be > 29.days.from_now expect(rec.expires_at).to be > 29.days.from_now
expect(rec.metadata["auto_publish_youtube"]).to eq(false)
end end
it "skips free plan" do it "skips free plan" do
@@ -39,6 +41,18 @@ RSpec.describe Recordings::FinalizeSession do
expect(rec.expires_at).to be > 89.days.from_now expect(rec.expires_at).to be > 89.days.from_now
end end
it "non riazzerare un recording già ready su stop ripetuto" do
rec = described_class.new(session).call
rec.update!(status: "ready", storage_key: "teams/x/sessions/#{session.id}/replay.mp4", byte_size: 12_345)
again = described_class.new(session).call
expect(again.id).to eq(rec.id)
expect(again.status).to eq("ready")
expect(again.storage_key).to eq("teams/x/sessions/#{session.id}/replay.mp4")
expect(again.byte_size).to eq(12_345)
expect(again.error_message).to be_nil
end
it "non crea recording se abbonamento scaduto" do it "non crea recording se abbonamento scaduto" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_light") Billing::AssignPlan.call(club: club, plan_slug: "premium_light")
club.subscription.update!(status: "canceled") club.subscription.update!(status: "canceled")
@@ -0,0 +1,79 @@
require "rails_helper"
require "zlib"
require "rubygems/package"
RSpec.describe Recordings::PullFromCloudNode do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "cpx-rec@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:home) do
StreamNode.create!(
slug: "spec-home-cpx-rec",
hostname: "home.local",
role: "home",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://home/live",
hls_base_url: "http://home/hls",
api_base_url: "http://home:9997",
max_publishers: 2,
max_relays: 2
)
end
let!(:cloud) do
StreamNode.create!(
slug: "spec-cpx-rec",
hostname: "cpx.example",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://cpx/live",
hls_base_url: "http://cpx/hls",
api_base_url: "http://cpx:9997",
max_publishers: 4,
max_relays: 4,
metadata: { "public_ip" => "203.0.113.10" }
)
end
def session_on(node)
StreamSession.create!(match: match, user: user, platform: "youtube", status: "ended", stream_node: node)
end
it "non è applicable sul nodo home" do
expect(described_class.new(session_on(home)).applicable?).to eq(false)
end
it "è applicable sul CPX con agent URL" do
expect(described_class.new(session_on(cloud)).applicable?).to eq(true)
end
it "estrae i segmenti dal tar dell'agent" do
session = session_on(cloud)
raw = "fake-mp4-bytes"
tar_io = StringIO.new
Zlib::GzipWriter.wrap(tar_io) do |gz|
Gem::Package::TarWriter.new(gz) do |tar|
tar.add_file_simple("clip.mp4", 0o644, raw.bytesize) { |io| io.write(raw) }
end
end
gz_bytes = tar_io.string
http = instance_double(Net::HTTP)
allow(Net::HTTP).to receive(:new).and_return(http)
allow(http).to receive(:open_timeout=)
allow(http).to receive(:read_timeout=)
success = Net::HTTPOK.new("1.1", "200", "OK")
allow(success).to receive(:body).and_return(gz_bytes)
allow(http).to receive(:request).and_return(success)
dest = described_class.new(session).fetch
expect(dest).to be_present
files = Dir.glob(File.join(dest, "**", "*.mp4"))
expect(files.size).to eq(1)
expect(File.binread(files.first)).to eq(raw)
ensure
FileUtils.remove_entry(dest) if dest && Dir.exist?(dest)
end
end
@@ -0,0 +1,40 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Recordings::StoragePolicy do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
def session_for(platform)
StreamSession.create!(
match: match,
user: user,
platform: platform,
status: "ended",
privacy_status: "public",
ended_at: Time.current
)
end
before do
load Rails.root.join("db/seeds/plans.rb")
end
it "returns temporary for youtube with premium" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
expect(described_class.call(session_for("youtube"))).to eq("temporary")
end
it "returns retained for matchlivetv with premium" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_light")
expect(described_class.call(session_for("matchlivetv"))).to eq("retained")
end
it "returns none without recordings entitlement" do
Billing::AssignPlan.call(club: club, plan_slug: "free")
expect(described_class.call(session_for("youtube"))).to eq("none")
end
end
@@ -0,0 +1,243 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "YouTube temporary replay retention" do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "coach@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
end
def youtube_session!(broadcast_id: "mock_broadcast_abc")
StreamSession.create!(
match: match,
user: user,
platform: "youtube",
status: "ended",
privacy_status: "public",
ended_at: Time.current,
youtube_broadcast_id: broadcast_id,
stream_key: "sk",
rtmp_url: "rtmp://a.rtmp.youtube.com/live2"
)
end
def mltv_session!
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "ended",
privacy_status: "public",
ended_at: Time.current
)
end
def create_temp_ready!(session, storage_key: nil)
key = storage_key || "temporary_replays/teams/#{team.id}/sessions/#{session.id}/replay.mp4"
Recording.create!(
stream_session: session,
team: team,
status: "ready",
storage_policy: "temporary",
storage_key: key,
byte_size: 1_024,
duration_secs: 120,
temp_expires_at: 48.hours.from_now,
expires_at: nil,
privacy_status: "public",
metadata: { "source_platform" => "youtube", "auto_publish_youtube" => false }
)
end
# A. YT → temp → verify OK → metadata → temp eliminata
it "A: verify OK sets youtube metadata and clears temporary media" do
session = youtube_session!
recording = create_temp_ready!(session)
storage = instance_double(Recordings::Storage, delete: true)
allow(Recordings::Storage).to receive(:new).and_return(storage)
result = Recordings::VerifyYoutubeReplay.new(recording).call
expect(result).to be_ok
recording.reload
expect(recording.youtube_video_id).to eq("mock_broadcast_abc")
expect(recording.youtube_verified_at).to be_present
expect(recording).to be_ready
expect(recording.available_in_archive?).to eq(true)
expect(recording.replay_source).to eq("youtube")
Recordings::ClearTemporaryMedia.new(recording, reason: :verified, force: true).call
recording.reload
expect(recording.storage_key).to be_nil
expect(recording.local_media_purged_at).to be_present
expect(recording.youtube_video_id).to eq("mock_broadcast_abc")
expect(recording).to be_ready
expect(recording.available_in_archive?).to eq(true)
expect(storage).to have_received(:delete).with(key: a_string_including("temporary_replays/"))
end
# B. YT → YT non pronto → temp non eliminata
it "B: does not clear temp when VOD is not ready" do
session = youtube_session!(broadcast_id: "real_broadcast_id")
recording = create_temp_ready!(session)
allow_any_instance_of(Youtube::VodStatus).to receive(:fetch).and_return(
Youtube::VodStatus::Result.new(ready: false, video_id: "real_broadcast_id", upload_status: "uploaded")
)
result = Recordings::VerifyYoutubeReplay.new(recording).call
expect(result).to be_retriable
expect(recording.reload.youtube_verified_at).to be_nil
Recordings::ClearTemporaryMedia.new(recording, reason: :verified, force: false).call
expect(recording.reload.storage_key).to be_present
expect(recording.local_media_purged_at).to be_nil
end
# C. YT → verify fail → max retention → safety + log
it "C: safety net purges at temp_expires_at even if unverified" do
session = youtube_session!
recording = create_temp_ready!(session)
recording.update!(temp_expires_at: 1.hour.ago, youtube_verified_at: nil)
storage = instance_double(Recordings::Storage, delete: true)
allow(Recordings::Storage).to receive(:new).and_return(storage)
expect(Rails.logger).to receive(:warn).with(/anomaly_unverified_expiry/).at_least(:once)
Recordings::PurgeTemporaryMediaJob.new.perform
recording.reload
expect(recording.local_media_purged_at).to be_present
expect(recording.storage_key).to be_nil
expect(recording.deleted_at).to be_nil
expect(recording.status).to eq("ready")
end
# D. solo MLTV → retained + retention piano
it "D: matchlivetv finalize uses retained and plan retention" do
session = mltv_session!
rec = Recordings::FinalizeSession.new(session).call
expect(rec.storage_policy).to eq("retained")
expect(rec.expires_at).to be > 89.days.from_now
expect(rec.temp_expires_at).to be_nil
expect(rec.metadata["auto_publish_youtube"]).to eq(false)
end
# E. no replay piano → none
it "E: free plan skips finalize (none)" do
Billing::AssignPlan.call(club: club, plan_slug: "free")
session = youtube_session!
expect(Recordings::StoragePolicy.call(session)).to eq("none")
expect(Recordings::FinalizeSession.new(session).call).to be_nil
end
# F. job doppio → idempotente
it "F: clear temporary media is idempotent" do
session = youtube_session!
recording = create_temp_ready!(session)
recording.update!(youtube_video_id: "mock_broadcast_abc", youtube_verified_at: Time.current)
storage = instance_double(Recordings::Storage, delete: true)
allow(Recordings::Storage).to receive(:new).and_return(storage)
Recordings::ClearTemporaryMedia.new(recording, reason: :verified, force: true).call
Recordings::ClearTemporaryMedia.new(recording.reload, reason: :verified, force: true).call
expect(storage).to have_received(:delete).once
expect(recording.reload.local_media_purged_at).to be_present
end
# G. file assente → cleanup ok
it "G: clear succeeds when storage delete raises (missing object)" do
session = youtube_session!
recording = create_temp_ready!(session)
recording.update!(youtube_verified_at: Time.current, youtube_video_id: "mock_broadcast_abc")
storage = instance_double(Recordings::Storage)
allow(storage).to receive(:delete).and_raise(Recordings::Storage::Error, "NoSuchKey")
allow(Recordings::Storage).to receive(:new).and_return(storage)
expect do
Recordings::ClearTemporaryMedia.new(recording, reason: :verified, force: true).call
end.not_to raise_error
expect(recording.reload.local_media_purged_at).to be_present
expect(recording.storage_key).to be_nil
end
# H. archivio ok senza S3
it "H: available_in_archive without storage_key when youtube linked" do
session = youtube_session!
recording = create_temp_ready!(session)
recording.update!(
storage_key: nil,
local_media_purged_at: Time.current,
youtube_video_id: "abc123XYZ01",
youtube_verified_at: Time.current
)
expect(recording.available_in_archive?).to eq(true)
expect(recording.playable_on_site?).to eq(true)
expect(recording.replay_source).to eq("youtube")
expect(recording.playback_stream_url).to be_nil
expect(recording.download_api_path).to be_nil
end
# I. API replay_source
it "I: exposes replay_source youtube for youtube-only ready recording" do
session = youtube_session!
recording = create_temp_ready!(session)
recording.update!(
storage_key: nil,
youtube_video_id: "abc123XYZ01",
youtube_verified_at: Time.current,
local_media_purged_at: Time.current
)
expect(recording.replay_source).to eq("youtube")
expect(recording.youtube_watch_url).to include("abc123XYZ01")
end
# J. download assente per youtube-only
it "J: DownloadUrl rejects youtube-only without storage_key" do
session = youtube_session!
recording = create_temp_ready!(session)
recording.update!(
storage_key: nil,
youtube_video_id: "abc123XYZ01",
youtube_verified_at: Time.current
)
expect do
Recordings::DownloadUrl.new(recording, viewer: user).call
end.to raise_error(ArgumentError, /File non disponibile/)
end
it "finalize youtube sets temporary policy and temp_expires_at" do
session = youtube_session!
rec = Recordings::FinalizeSession.new(session).call
expect(rec.storage_policy).to eq("temporary")
expect(rec.expires_at).to be_nil
expect(rec.temp_expires_at).to be_within(2.seconds).of(48.hours.from_now)
expect(rec.metadata["auto_publish_youtube"]).to eq(false)
end
it "PostProcess schedules verify for temporary, not PublishToYoutube" do
session = youtube_session!
recording = create_temp_ready!(session)
expect(Recordings::VerifyYoutubeReplayJob).to receive(:perform_in)
expect(Recordings::PublishToYoutubeJob).not_to receive(:perform_async)
Recordings::PostProcessJob.new.perform(recording.id)
end
it "PostProcess still schedules verify if notify/mailer fails" do
session = youtube_session!
recording = create_temp_ready!(session)
allow_any_instance_of(Recordings::NotifyReady).to receive(:call).and_raise(Errno::ECONNREFUSED)
expect(Recordings::VerifyYoutubeReplayJob).to receive(:perform_in)
expect(Recordings::PublishToYoutubeJob).not_to receive(:perform_async)
Recordings::PostProcessJob.new.perform(recording.id)
end
it "expired_pending_purge ignores temporary with nil expires_at" do
session = youtube_session!
create_temp_ready!(session)
expect(Recording.expired_pending_purge.count).to eq(0)
end
end
@@ -0,0 +1,45 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Sessions::ApplyClientInfo do
let!(:user) { User.create!(email: "client-info@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") }
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle") }
it "applica i campi client sulla sessione" do
described_class.call(session, {
os: "android",
app_version: "1.4.0",
app_build: "42",
device_manufacturer: "Samsung",
device_model: "SM-G991B",
os_version: "14",
carrier: "TIM"
})
session.reload
expect(session.client_os).to eq("android")
expect(session.app_version).to eq("1.4.0")
expect(session.app_build).to eq("42")
expect(session.device_manufacturer).to eq("Samsung")
expect(session.device_model).to eq("SM-G991B")
expect(session.os_version).to eq("14")
expect(session.carrier).to eq("TIM")
end
it "ignora os sconosciuti" do
described_class.call(session, { os: "windows" })
expect(session.reload.client_os).to be_nil
end
it "assegna senza salvare su record non persistito" do
draft = StreamSession.new(match: match, user: user, platform: "matchlivetv", status: "idle")
described_class.call(draft, { os: "ios", app_version: "2.0.0" })
expect(draft).not_to be_persisted
expect(draft.client_os).to eq("ios")
expect(draft.app_version).to eq("2.0.0")
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Sessions::Create, "capacity / autoscaler kick" do
def with_env(vars)
previous = vars.keys.index_with { |k| ENV[k] }
vars.each { |k, v| ENV[k] = v }
yield
ensure
previous.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
end
let(:redis) { Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0")) }
let(:user) { User.create!(email: "cap@example.com", name: "C", password: "Password123", role: "coach") }
let(:club) { Club.create!(name: "Cap Club", sport: "volleyball") }
let(:team) { club.teams.create!(name: "Cap Team", sport: "volleyball", slug: "cap-team") }
let(:match) { team.matches.create!(opponent_name: "Opp", scheduled_at: 1.hour.from_now) }
before do
redis.del(Streams::AutoscalerJob::KICK_DEBOUNCE_KEY)
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
end
it "kicks AutoscalerJob and raises stream_capacity_scaling when autoscaler is on" do
with_env("STREAM_AUTOSCALE_ENABLED" => "1") do
allow(Streams::NodeRegistry).to receive(:allocate!).and_raise(Streams::NodeRegistry::NoCapacityError, "full")
expect(Streams::AutoscalerJob).to receive(:kick!).and_return(true)
expect {
described_class.new(user: user, match: match, params: { platform: "matchlivetv" }).call
}.to raise_error(Teams::EntitlementError) { |e|
expect(e.code).to eq("stream_capacity_scaling")
}
end
end
it "raises stream_capacity_exhausted when autoscaler is off" do
with_env("STREAM_AUTOSCALE_ENABLED" => "0") do
allow(Streams::NodeRegistry).to receive(:allocate!).and_raise(Streams::NodeRegistry::NoCapacityError, "full")
expect(Streams::AutoscalerJob).not_to receive(:kick!)
expect {
described_class.new(user: user, match: match, params: { platform: "matchlivetv" }).call
}.to raise_error(Teams::EntitlementError) { |e|
expect(e.code).to eq("stream_capacity_exhausted")
}
end
end
end
@@ -224,4 +224,41 @@ RSpec.describe Streams::Autoscaler do
expect(described_class.within_budget?(1)).to eq(false) expect(described_class.within_budget?(1)).to eq(false)
end end
end end
it "promotes provisioning nodes when MediaMTX becomes reachable" do
with_env(
"STREAM_AUTOSCALE_ENABLED" => "1",
"STREAM_AUTOSCALE_WARM_SPARE" => "0",
"STREAM_AUTOSCALE_SOFT_FREE_SLOTS" => "0",
"STREAM_AUTOSCALE_KIND" => "lab",
"MEDIAMTX_API_URL" => "http://mtx-home:9997",
"MEDIAMTX_RTMP_URL" => "rtmp://home.example:1935",
"HLS_PUBLIC_URL" => "https://home.example/hls"
) do
Streams::NodeRegistry.ensure_home_from_env!
node = StreamNode.create!(
slug: "ingest-lab-prov",
hostname: "prov.lab",
role: "lab",
status: "provisioning",
provider: "local",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://h:9997",
max_publishers: 2,
max_relays: 2
)
allow(Streams::NodeHealth).to receive(:promote_if_healthy!) do |n|
n.update!(status: "ready", last_health_at: Time.current)
true
end
provisioner = instance_double(Streams::NodeProvisioner)
expect(provisioner).not_to receive(:provision_lab!)
result = described_class.reconcile!(provisioner: provisioner)
expect(result.actions).to include(:"ready_ingest-lab-prov")
expect(node.reload.status).to eq("ready")
end
end
end end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Streams::NodeHealth do
let!(:node) do
StreamNode.create!(
slug: "ingest-health-01",
hostname: "ingest-health-01.mltv-stream.net",
role: "cloud",
status: "provisioning",
provider: "hetzner",
rtmp_base_url: "rtmp://h:1935",
hls_base_url: "https://h/hls",
api_base_url: "http://203.0.113.10:9997",
max_publishers: 4,
max_relays: 4
)
end
after { node.destroy }
it "promotes provisioning node when MediaMTX is reachable" do
client = instance_double(Mediamtx::Client, reachable?: true)
allow(Mediamtx::Client).to receive(:new).with(base_url: node.api_base_url).and_return(client)
expect(described_class.promote_if_healthy!(node)).to eq(true)
expect(node.reload.status).to eq("ready")
expect(node.last_health_at).to be_present
end
it "does not promote when MediaMTX is down" do
client = instance_double(Mediamtx::Client, reachable?: false)
allow(Mediamtx::Client).to receive(:new).with(base_url: node.api_base_url).and_return(client)
expect(described_class.promote_if_healthy!(node)).to eq(false)
expect(node.reload.status).to eq("provisioning")
end
end
@@ -24,11 +24,15 @@ RSpec.describe "Streams::NodeProvisioner cloud" do
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net" ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4" ENV["STREAM_CLOUD_MAX_PUBLISHERS"] = "4"
ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0" ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0"
ENV["STREAM_NODE_READY_TIMEOUT_SECS"] = "0"
allow(Streams::NodeHealth).to receive(:promote_if_healthy!).and_return(false)
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud! node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
expect(node.slug).to eq("ingest-01") expect(node.slug).to eq("ingest-01")
expect(node.role).to eq("cloud") expect(node.role).to eq("cloud")
expect(node.provider).to eq("hetzner") expect(node.provider).to eq("hetzner")
expect(node.status).to eq("provisioning")
expect(node.hostname).to eq("ingest-01.mltv-stream.net") expect(node.hostname).to eq("ingest-01.mltv-stream.net")
expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935") expect(node.rtmp_base_url).to eq("rtmp://ingest-01.mltv-stream.net:1935")
expect(node.api_base_url).to eq("http://10.0.0.9:9997") expect(node.api_base_url).to eq("http://10.0.0.9:9997")
@@ -39,6 +43,45 @@ RSpec.describe "Streams::NodeProvisioner cloud" do
%w[ %w[
MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL
STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS STREAM_CLOUD_PUBLIC_CONTROL STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_MAX_PUBLISHERS STREAM_CLOUD_PUBLIC_CONTROL
STREAM_NODE_READY_TIMEOUT_SECS
].each { |k| ENV.delete(k) }
end
it "marks cloud node ready when MediaMTX answers during wait" do
cloud = instance_double(
Streams::CloudProviders::Hetzner,
create_node: Streams::CloudProviders::Instance.new(
id: "100",
name: "mltv-stream-ingest-01",
public_ip: "49.13.9.9",
private_ip: "10.0.0.9",
status: "running",
raw: {}
)
)
dns = instance_double(Streams::DnsProviders::Hetzner)
allow(dns).to receive(:upsert_a)
ENV["MEDIAMTX_API_URL"] = "http://mtx-home:9997"
ENV["MEDIAMTX_RTMP_URL"] = "rtmp://home.example:1935"
ENV["HLS_PUBLIC_URL"] = "https://home.example/hls"
ENV["STREAM_CLOUD_DNS_SUFFIX"] = "mltv-stream.net"
ENV["STREAM_CLOUD_PUBLIC_CONTROL"] = "0"
ENV["STREAM_NODE_READY_TIMEOUT_SECS"] = "5"
ENV["STREAM_NODE_READY_POLL_SECS"] = "0.01"
allow(Streams::NodeHealth).to receive(:promote_if_healthy!) do |node|
node.update!(status: "ready", last_health_at: Time.current)
true
end
node = Streams::NodeProvisioner.new(cloud: cloud, dns: dns).provision_cloud!
expect(node.status).to eq("ready")
ensure
%w[
MEDIAMTX_API_URL MEDIAMTX_RTMP_URL HLS_PUBLIC_URL
STREAM_CLOUD_DNS_SUFFIX STREAM_CLOUD_PUBLIC_CONTROL
STREAM_NODE_READY_TIMEOUT_SECS STREAM_NODE_READY_POLL_SECS
].each { |k| ENV.delete(k) } ].each { |k| ENV.delete(k) }
end end
end end
+86 -99
View File
@@ -2,156 +2,143 @@
## Panoramica ## Panoramica
Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce i segmenti, genera thumbnail, carica su **Garage (S3-compatible)** e attiva i servizi di notifica, statistiche e (opzionale) republicazione YouTube. Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce i segmenti, genera thumbnail e carica su **Garage (S3-compatible)**.
- **Live MatchLiveTV (HLS)**: copia **permanente** (policy `retained`) con retention del piano.
- **Live YouTube**: copia **temporanea** di sicurezza (policy `temporary`, default 48h), poi player YouTube in archivio. **Niente** re-upload automatico del MP4 su YouTube.
## Piani e retention ## Piani e retention
| Piano | Registrazione | Retention | Download MP4 | YouTube VOD | | Piano | Registrazione | Retention archivio MLTV | Download MP4 | YouTube |
|-------|---------------|-----------|--------------|-------------| |-------|---------------|-------------------------|--------------|---------|
| Free | No | — | No | No | | Free | No | — | No | No |
| Premium Light | Sì | 30 giorni | Sì | No | | Premium Light | Sì | 30 giorni (`expires_at`) | Sì (solo se file MLTV) | No |
| Premium Full | Sì | 90 giorni | Sì | Sì (auto se diretta YouTube) | | Premium Full | Sì | 90 giorni (`expires_at`) se HLS; **nessuna scadenza archivio** se YouTube (`expires_at` nil) | Sì solo con file Garage | Live → VOD nativo; verify collega `youtube_video_id` |
## Storage policy (`Recordings::StoragePolicy`)
```text
if youtube_destination? → temporary
elsif can_create_recordings? → retained
else → none
```
| Policy | Prefisso storage | `expires_at` (archivio) | `temp_expires_at` |
|--------|------------------|-------------------------|-------------------|
| `temporary` | `temporary_replays/...` | `nil` | now + `YOUTUBE_TEMP_REPLAY_RETENTION_HOURS` (2472, default 48) |
| `retained` | `teams/...` | piano `recording_days` | `nil` |
| `none` | — | — | — |
## Archivio logico vs storage fisico
Dopo cleanup della copia temp, la partita **resta** in archivio MatchLiveTV. Cambia solo la sorgente:
| `replay_source` | Player | File Garage permanente |
|-----------------|--------|------------------------|
| `youtube` | embed / link YouTube | no (MP4 temp già eliminato) |
| `matchlivetv` | player MP4 proprietario | sì |
| `none` | non playable | — |
`available_in_archive?` = `ready` e (YouTube URL/`youtube_video_id` **oppure** `storage_key`).
Clear/purge temporary: **solo** oggetti S3 video; mai soft-delete; mai azzerare `youtube_video_id` / metadata / score. Thumbnail preferibilmente conservata.
## Flusso YouTube
```text
Stop → StoragePolicy(temporary) → Finalize (expires_at nil, temp_expires_at)
→ Upload temporary_replays/... → ready
→ VerifyYoutubeReplayJob (backoff)
→ ok: youtube_video_id + youtube_verified_at → ClearTemporaryMediaJob
→ fail fino a max attempts: aspetta safety net
→ PurgeTemporaryMediaJob (cron): a temp_expires_at elimina file + log se non verificato
```
## Funzionalità implementate ## Funzionalità implementate
### 1. Registrazione automatica ### 1. Registrazione automatica
Pipeline: `Sessions::Stop``FinalizeSession``UploadJob` → storage. Pipeline: `Sessions::Stop``FinalizeSession``UploadJob` → storage`PostProcessJob`.
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». MediaMTX registra **solo mentre il telefono pubblica** (RTMP connesso).
### 2. Retention e purge ### 2. Retention e purge
`Recordings::PurgeExpiredJob` / `rails recordings:purge_expired` - Retained: `Recordings::PurgeExpiredJob` / `rails recordings:purge_expired` (soft-delete + S3 + YouTube se collegato)
- Temporary: `rails recordings:purge_temporary` (solo storage)
### 3. Archivio Replay ### 3. Archivio Replay
- Web: `/clubs/:id/replays` (gestione società) - Web: `/clubs/:id/replays` (gestione società)
- Pubblico: `/replay` con filtro società/squadra - Pubblico: `/replay` con filtro società/squadra
- App mobile: **nessuna** UI replay (gestione solo da sito web) - Stessa UX listing per YouTube e MLTV (differisce solo il player)
### 4. Riproduzione ### 4. Riproduzione
- Player MP4: `/replay/:id` + stream `/replay/:id/stream` - YouTube: embed su `/replay/:id`
- Delivery: Rails verifica accesso → redirect 302 a `/media/...` (edge → Garage). Puma non proxya il file. - MLTV: MP4 via `/replay/:id/stream` → redirect `/media/...` → Garage
- Contatore visualizzazioni su ogni play (`view_count`) - Contatore visualizzazioni su ogni play (`view_count`)
### 5. Visibilità ### 5. Visibilità
- **Pubblico** (`public`): compare in `/replay`, indicizzabile, YouTube `public` Pubblico / privato (`unlisted`) come prima. Sync privacy YouTube se `youtube_video_id` presente.
- **Privato** (`unlisted`): non in catalogo pubblico, accessibile solo con link diretto, `noindex`, YouTube `unlisted`
Modificabile dallarchivio società (`/clubs/:id/replays`). Il cambio privacy sincronizza automaticamente il VOD YouTube se presente (`Recordings::SyncYoutubePrivacyJob`).
### 6. Eliminazione anticipata ### 6. Eliminazione anticipata
`Recordings::Delete` — elimina video YouTube collegato, oggetti S3/Garage (MP4 + thumbnail) e soft-delete DB. `Recordings::Delete` manuale — resta distruttiva sul record (distinta dal cleanup temporary automatico).
Stesso comportamento alla scadenza retention (`PurgeExpiredJob` alle 03:00). ### 78. KPI, email, thumbnail, download, views — invariati dove applicabili.
### 7. Dashboard KPI ## Config ENV
Replay disponibili, in scadenza (7 gg), spazio occupato, **visualizzazioni totali**.
### 8. Miglioramenti Premium
| Feature | Descrizione |
|---------|-------------|
| **Email replay pronto** | A owner e membri società quando status → `ready` |
| **Email scadenza** | 7 giorni prima di `expires_at` (`rails recordings:expiry_warnings`) |
| **Thumbnail** | Frame ffmpeg, URL `/replay/:id/thumbnail` |
| **Download MP4** | Premium, link presigned 15 min o `/replay/:id/download` |
| **Statistiche views** | `view_count` su ogni replay |
| **metadata JSONB** | `source_platform`, `auto_publish_youtube`, `ai: {}` per estensioni |
| **YouTube VOD** | Premium Full: upload automatico se diretta era su YouTube; manuale da archivio |
## Storage
```env ```env
YOUTUBE_TEMP_REPLAY_RETENTION_HOURS=48
YOUTUBE_REPLAY_VERIFY_MAX_ATTEMPTS=12
YOUTUBE_REPLAY_VERIFY_BASE_INTERVAL_SECS=300
YOUTUBE_REPLAY_VERIFY_GRACE_SECS=120
REPLAY_STORAGE_ENDPOINT=http://garage:3900 REPLAY_STORAGE_ENDPOINT=http://garage:3900
REPLAY_STORAGE_BUCKET=matchlivetv-replays REPLAY_STORAGE_BUCKET=matchlivetv-replays
REPLAY_STORAGE_ACCESS_KEY_ID=... ...
REPLAY_STORAGE_SECRET_ACCESS_KEY=...
REPLAY_DOWNLOAD_URL_TTL_SECONDS=900
``` ```
Valori letti da `MatchLiveTv` (`config/initializers/match_live_tv.rb`).
## Storage Garage
Senza endpoint S3: storage locale `/recordings/replays/`. Senza endpoint S3: storage locale `/recordings/replays/`.
### Dev locale con Garage ### Dev / produzione Garage
Vedi script `infra/scripts/setup_garage_*.sh` come in precedenza.
```bash
cd infra
docker compose up -d # include garage
bash scripts/setup_garage_replays.sh # layout, bucket, chiave → .env
docker compose up -d rails sidekiq
# Verifica pipeline (segmento finto → Garage)
docker compose exec -T rails bundle exec rails replay:e2e_garage
```
### Produzione (`/opt/matchlivetv`)
```bash
cd infra
bash scripts/setup_garage_production.sh # garage.prod.toml, bucket, chiavi → .env
# Rails/Sidekiq usano REPLAY_STORAGE_ENDPOINT=http://garage:3900 (rete Docker)
```
**Credenziali in `.env`:** non compilarle a mano in `.env.example`. Lo script scrive in `infra/.env`:
- `REPLAY_STORAGE_ACCESS_KEY_ID` = Key ID Garage (es. `GK...`)
- `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave
## Cron (produzione) ## Cron (produzione)
Installazione sul server:
```bash ```bash
cd /opt/matchlivetv/infra cd /opt/matchlivetv/infra
bash scripts/install_production_cron.sh bash scripts/install_production_cron.sh
``` ```
Job attivi (utente `eminux`):
| Orario | Task | | Orario | Task |
|--------|------| |--------|------|
| 03:00 | `recordings:purge_expired`elimina replay scaduti (DB + Garage) | | 03:00 | `recordings:purge_expired`replay retained scaduti |
| 03:30 | `recordings:purge_temporary` — copie temp YouTube scadute |
| 08:00 | `recordings:expiry_warnings` — email avviso scadenza (7 gg) | | 08:00 | `recordings:expiry_warnings` — email avviso scadenza (7 gg) |
Log: `${MATCHLIVETV_VIDEOS_ROOT}/log/cron-replay.log` (es. `/media/videos/matchlivetv/log/cron-replay.log`) ### Audit duplicati storici
### Capacità Garage
Allinstallazione, `setup_garage_production.sh` assegna ~**(disco libero 20 GB)** al nodo.
Per ridimensionare dopo:
```bash ```bash
bash scripts/garage_set_capacity.sh 85G # oppure senza argomento: calcolo automatico rails recordings:audit_youtube_duplicates # dry-run di default
``` ```
## Piattaforme live e replay Nessun cleanup automatico dei vecchi MP4 YouTube già in Garage.
| Origine diretta | Copia Garage/S3 | Player sito `/replay/:id` | YouTube VOD | ## API
|-----------------|-----------------|----------------------------|-------------|
| `matchlivetv` | Sì (Premium) | MP4 via redirect `/media/` → Garage | Manuale (Premium Full) |
| `youtube` | Sì (Premium) | MP4 via redirect `/media/` → Garage | Auto se Premium Full + diretta YouTube |
Se il file S3 non è disponibile ma esiste `youtube_video_id`, la pagina replay mostra embed YouTube. - `GET /teams/:id/recordings` — include `replay_source`, `youtube_watch_url`, `playback_url` (null se youtube-only), `download_enabled` solo con `storage_key`
- `GET /recordings/:id/download` — richiede file Garage
- `POST /recordings/:id/publish_youtube` — solo MatchLiveTV-only con file (non per live già YouTube)
## Retention e abbonamento ## Colonne DB (migration)
- `expires_at` viene impostato alla fine diretta: **30 giorni** (Premium Light), **90 giorni** (Premium Full) - `storage_policy` (default `retained`)
- `expires_at` **non cambia** al cambio piano successivo - `temp_expires_at`, `youtube_verified_at`, `local_media_purged_at`
- **Nuove registrazioni** richiedono abbonamento attivo (`can_create_recordings?`)
- **Archivio esistente** resta accessibile fino a `expires_at` anche se labbonamento scade (`can_access_recordings?`)
## API mobile ## Rischi
- `GET /teams/:id/recordings` — lista con thumbnail, views, download_enabled - VOD YouTube in ritardo oltre retention temp → safety net elimina file; archivio resta se id collegato; altrimenti anomaly log.
- `GET /recordings/:id/download` — URL download temporaneo - Thumb: se manca storage, poster YouTube da metadata / default.
- `POST /recordings/:id/publish_youtube` — coda republicazione YouTube - Record storici restano `storage_policy=retained`.
## Estensioni future (metadata.ai)
Campo `metadata["ai"]` predisposto per:
- highlight automatici
- trascrizioni
- articoli generati
- clip / Shorts
Esempio aggiornamento via API:
```json
{ "recording": { "metadata": { "ai": { "transcript_status": "queued" } } } } }
```
+6
View File
@@ -64,3 +64,9 @@ REPLAY_STORAGE_FORCE_PATH_STYLE=true
# Opzionale: durata link firmati per play/download (secondi) # Opzionale: durata link firmati per play/download (secondi)
# REPLAY_PRESIGNED_URL_TTL_SECONDS=7200 # REPLAY_PRESIGNED_URL_TTL_SECONDS=7200
# REPLAY_DOWNLOAD_URL_TTL_SECONDS=900 # REPLAY_DOWNLOAD_URL_TTL_SECONDS=900
# Replay YouTube: copia temporanea di sicurezza (ore, clamp 2472, default 48)
# YOUTUBE_TEMP_REPLAY_RETENTION_HOURS=48
# YOUTUBE_REPLAY_VERIFY_MAX_ATTEMPTS=12
# YOUTUBE_REPLAY_VERIFY_BASE_INTERVAL_SECS=300
# YOUTUBE_REPLAY_VERIFY_GRACE_SECS=120
+5
View File
@@ -78,6 +78,11 @@ REPLAY_STORAGE_FORCE_PATH_STYLE=true
# Streaming replay: Rails fa auth, edge serve /media/ → Garage (non passa da Puma) # Streaming replay: Rails fa auth, edge serve /media/ → Garage (non passa da Puma)
REPLAY_MEDIA_PUBLIC_BASE_URL=https://www.matchlivetv.it/media REPLAY_MEDIA_PUBLIC_BASE_URL=https://www.matchlivetv.it/media
REPLAY_MEDIA_REDIRECT=true REPLAY_MEDIA_REDIRECT=true
# Copia temporanea post-live YouTube (ore, clamp 2472)
YOUTUBE_TEMP_REPLAY_RETENTION_HOURS=48
YOUTUBE_REPLAY_VERIFY_MAX_ATTEMPTS=12
YOUTUBE_REPLAY_VERIFY_BASE_INTERVAL_SECS=300
YOUTUBE_REPLAY_VERIFY_GRACE_SECS=120
# Puma: 5 thread (no secondo worker senza più RAM) # Puma: 5 thread (no secondo worker senza più RAM)
RAILS_MAX_THREADS=5 RAILS_MAX_THREADS=5
+1
View File
@@ -28,6 +28,7 @@ MARKER="# matchlivetv-replay-cron"
CRON_BLOCK="${MARKER} CRON_BLOCK="${MARKER}
30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1 30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1
0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1 0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1
30 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_temporary >> ${LOG_FILE} 2>&1
0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1 0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1
15 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} analytics:aggregate >> ${LOG_DIR}/cron-analytics.log 2>&1 15 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} analytics:aggregate >> ${LOG_DIR}/cron-analytics.log 2>&1
20 4 * * * mkdir -p ${LOG_DIR} && ${RUNNER} analytics:purge >> ${LOG_DIR}/cron-analytics.log 2>&1 20 4 * * * mkdir -p ${LOG_DIR} && ${RUNNER} analytics:purge >> ${LOG_DIR}/cron-analytics.log 2>&1
+66 -7
View File
@@ -57,6 +57,7 @@ write_files:
STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888 STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888
STREAM_NODE_RELAY_LOG_DIR=/var/log STREAM_NODE_RELAY_LOG_DIR=/var/log
STREAM_NODE_SLATES_DIR=/slates/custom STREAM_NODE_SLATES_DIR=/slates/custom
STREAM_NODE_RECORDINGS_DIR=/recordings
- path: /opt/stream-node/relay-agent.py - path: /opt/stream-node/relay-agent.py
permissions: "0755" permissions: "0755"
content: | content: |
@@ -68,17 +69,21 @@ write_files:
import json import json
import os import os
import shutil
import signal import signal
import subprocess import subprocess
import tarfile
import tempfile
import threading import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse from urllib.parse import unquote, urlparse
SECRET = os.environ.get("STREAM_NODE_AGENT_SECRET", "") SECRET = os.environ.get("STREAM_NODE_AGENT_SECRET", "")
LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100") LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100")
HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/") HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/")
LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log") LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log")
SLATES_DIR = os.environ.get("STREAM_NODE_SLATES_DIR", "/slates/custom") SLATES_DIR = os.environ.get("STREAM_NODE_SLATES_DIR", "/slates/custom")
RECORDINGS_DIR = os.environ.get("STREAM_NODE_RECORDINGS_DIR", "/recordings")
_lock = threading.Lock() _lock = threading.Lock()
# session_id -> {"pid": int, "path": str, "proc": Popen} # session_id -> {"pid": int, "path": str, "proc": Popen}
@@ -160,6 +165,27 @@ write_files:
pass pass
def _recordings_root(path_name):
name = unquote(path_name or "").strip().lstrip("/")
if not name or ".." in name.split("/"):
return None
base = os.path.realpath(RECORDINGS_DIR)
root = os.path.realpath(os.path.join(base, name))
if root != base and not root.startswith(base + os.sep):
return None
return root
def _has_recording_files(root: str) -> bool:
if not os.path.isdir(root):
return False
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
if name.lower().endswith((".mp4", ".fmp4", ".m4s", ".ts")):
return True
return False
def _start_session(session_id: str, path: str, rtmps: str) -> int: def _start_session(session_id: str, path: str, rtmps: str) -> int:
existing = _relays.get(session_id) existing = _relays.get(session_id)
if existing: if existing:
@@ -214,6 +240,30 @@ write_files:
return return
self._json(200, {"running": True, "session_id": session_id, "pid": info["pid"], "path": info["path"]}) self._json(200, {"running": True, "session_id": session_id, "pid": info["pid"], "path": info["path"]})
return return
if parsed.path.startswith("/recordings/"):
name = parsed.path.split("/recordings/", 1)[1].strip("/")
root = _recordings_root(name)
if not root or not _has_recording_files(root):
self._json(404, {"error": "not found"})
return
tmp = tempfile.NamedTemporaryFile(prefix="mltv-rec-", suffix=".tar.gz", delete=False)
tmp.close()
try:
with tarfile.open(tmp.name, "w:gz") as tar:
tar.add(root, arcname=".")
size = os.path.getsize(tmp.name)
self.send_response(200)
self.send_header("Content-Type", "application/gzip")
self.send_header("Content-Length", str(size))
self.end_headers()
with open(tmp.name, "rb") as fh:
shutil.copyfileobj(fh, self.wfile)
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
return
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
def do_POST(self) -> None: def do_POST(self) -> None:
@@ -244,13 +294,22 @@ write_files:
self._json(401, {"error": "unauthorized"}) self._json(401, {"error": "unauthorized"})
return return
parsed = urlparse(self.path) parsed = urlparse(self.path)
if not parsed.path.startswith("/relays/"): if parsed.path.startswith("/relays/"):
self._json(404, {"error": "not found"}) session_id = parsed.path.split("/relays/", 1)[1].strip("/")
with _lock:
_stop_session(session_id)
self._json(200, {"stopped": True, "session_id": session_id})
return return
session_id = parsed.path.split("/relays/", 1)[1].strip("/") if parsed.path.startswith("/recordings/"):
with _lock: name = parsed.path.split("/recordings/", 1)[1].strip("/")
_stop_session(session_id) root = _recordings_root(name)
self._json(200, {"stopped": True, "session_id": session_id}) if not root or not os.path.isdir(root):
self._json(404, {"error": "not found"})
return
shutil.rmtree(root, ignore_errors=True)
self._json(200, {"deleted": True, "path": name})
return
self._json(404, {"error": "not found"})
def do_PUT(self) -> None: def do_PUT(self) -> None:
if not _authorized(self): if not _authorized(self):
+65 -7
View File
@@ -6,17 +6,21 @@ from __future__ import annotations
import json import json
import os import os
import shutil
import signal import signal
import subprocess import subprocess
import tarfile
import tempfile
import threading import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse from urllib.parse import unquote, urlparse
SECRET = os.environ.get("STREAM_NODE_AGENT_SECRET", "") SECRET = os.environ.get("STREAM_NODE_AGENT_SECRET", "")
LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100") LISTEN = os.environ.get("STREAM_NODE_AGENT_LISTEN", "0.0.0.0:9100")
HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/") HLS_BASE = os.environ.get("STREAM_NODE_LOCAL_HLS_URL", "http://127.0.0.1:8888").rstrip("/")
LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log") LOG_DIR = os.environ.get("STREAM_NODE_RELAY_LOG_DIR", "/var/log")
SLATES_DIR = os.environ.get("STREAM_NODE_SLATES_DIR", "/slates/custom") SLATES_DIR = os.environ.get("STREAM_NODE_SLATES_DIR", "/slates/custom")
RECORDINGS_DIR = os.environ.get("STREAM_NODE_RECORDINGS_DIR", "/recordings")
_lock = threading.Lock() _lock = threading.Lock()
# session_id -> {"pid": int, "path": str, "proc": Popen} # session_id -> {"pid": int, "path": str, "proc": Popen}
@@ -98,6 +102,27 @@ def _stop_session(session_id: str) -> None:
pass pass
def _recordings_root(path_name):
name = unquote(path_name or "").strip().lstrip("/")
if not name or ".." in name.split("/"):
return None
base = os.path.realpath(RECORDINGS_DIR)
root = os.path.realpath(os.path.join(base, name))
if root != base and not root.startswith(base + os.sep):
return None
return root
def _has_recording_files(root: str) -> bool:
if not os.path.isdir(root):
return False
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
if name.lower().endswith((".mp4", ".fmp4", ".m4s", ".ts")):
return True
return False
def _start_session(session_id: str, path: str, rtmps: str) -> int: def _start_session(session_id: str, path: str, rtmps: str) -> int:
existing = _relays.get(session_id) existing = _relays.get(session_id)
if existing: if existing:
@@ -152,6 +177,30 @@ class Handler(BaseHTTPRequestHandler):
return return
self._json(200, {"running": True, "session_id": session_id, "pid": info["pid"], "path": info["path"]}) self._json(200, {"running": True, "session_id": session_id, "pid": info["pid"], "path": info["path"]})
return return
if parsed.path.startswith("/recordings/"):
name = parsed.path.split("/recordings/", 1)[1].strip("/")
root = _recordings_root(name)
if not root or not _has_recording_files(root):
self._json(404, {"error": "not found"})
return
tmp = tempfile.NamedTemporaryFile(prefix="mltv-rec-", suffix=".tar.gz", delete=False)
tmp.close()
try:
with tarfile.open(tmp.name, "w:gz") as tar:
tar.add(root, arcname=".")
size = os.path.getsize(tmp.name)
self.send_response(200)
self.send_header("Content-Type", "application/gzip")
self.send_header("Content-Length", str(size))
self.end_headers()
with open(tmp.name, "rb") as fh:
shutil.copyfileobj(fh, self.wfile)
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
return
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
def do_POST(self) -> None: def do_POST(self) -> None:
@@ -182,13 +231,22 @@ class Handler(BaseHTTPRequestHandler):
self._json(401, {"error": "unauthorized"}) self._json(401, {"error": "unauthorized"})
return return
parsed = urlparse(self.path) parsed = urlparse(self.path)
if not parsed.path.startswith("/relays/"): if parsed.path.startswith("/relays/"):
self._json(404, {"error": "not found"}) session_id = parsed.path.split("/relays/", 1)[1].strip("/")
with _lock:
_stop_session(session_id)
self._json(200, {"stopped": True, "session_id": session_id})
return return
session_id = parsed.path.split("/relays/", 1)[1].strip("/") if parsed.path.startswith("/recordings/"):
with _lock: name = parsed.path.split("/recordings/", 1)[1].strip("/")
_stop_session(session_id) root = _recordings_root(name)
self._json(200, {"stopped": True, "session_id": session_id}) if not root or not os.path.isdir(root):
self._json(404, {"error": "not found"})
return
shutil.rmtree(root, ignore_errors=True)
self._json(200, {"deleted": True, "path": name})
return
self._json(404, {"error": "not found"})
def do_PUT(self) -> None: def do_PUT(self) -> None:
if not _authorized(self): if not _authorized(self):
@@ -43,7 +43,11 @@ class E2ECoverUploadTest {
openMatchWizardViaQuickStart() openMatchWizardViaQuickStart()
waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000) waitForText(s(R.string.wizard_step_title_match), timeoutMs = 45_000)
scrollUntilVisible(s(R.string.wizard_match_cover_title)) scrollUntilVisible(
s(R.string.wizard_match_cover_title),
"Copertina in pausa",
"Pause cover",
)
tapClickableText(s(R.string.wizard_match_cover_change)) tapClickableText(s(R.string.wizard_match_cover_change))
pickFirstPhotoFromPicker() pickFirstPhotoFromPicker()
@@ -185,12 +189,12 @@ class E2ECoverUploadTest {
} }
} }
private fun scrollUntilVisible(text: String) { private fun scrollUntilVisible(vararg texts: String) {
repeat(8) { repeat(20) {
if (device.hasObject(By.text(text))) return if (texts.any { device.hasObject(By.text(it)) }) return
scrollDown(1) scrollDown(1)
} }
waitForText(text, timeoutMs = 10_000) waitForAnyText(*texts, timeoutMs = 10_000)
} }
private fun grantRuntimePermissions() { private fun grantRuntimePermissions() {
@@ -22,8 +22,8 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class E2EWizardFlowTest { class E2EWizardFlowTest {
private lateinit var device: UiDevice private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv"
private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext } private val ctx by lazy { InstrumentationRegistry.getInstrumentation().targetContext }
private val pkg by lazy { ctx.packageName }
private fun s(id: Int): String = ctx.getString(id) private fun s(id: Int): String = ctx.getString(id)
private fun su(id: Int): String = s(id).uppercase() private fun su(id: Int): String = s(id).uppercase()
@@ -20,7 +20,7 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class LocalAdaptiveBitrateUiTest { class LocalAdaptiveBitrateUiTest {
private lateinit var device: UiDevice private lateinit var device: UiDevice
private val pkg = "com.matchlivetv.match_live_tv" private val pkg by lazy { InstrumentationRegistry.getInstrumentation().targetContext.packageName }
@Before @Before
fun setUp() { fun setUp() {
@@ -47,8 +47,21 @@ class LocalAdaptiveBitrateUiTest {
tapAny("AVANTI >", "NEXT >") tapAny("AVANTI >", "NEXT >")
waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast") waitForAny(45_000, "02 · Trasmissione", "02 · Broadcast")
waitForAny(30_000, "Piattaforma", "Platform") waitForAny(30_000, "Piattaforma", "Platform")
scrollDown() // Come E2EWizardFlowTest: privacy non-in-elenco e AVANTI riprovato con scroll.
tapAny("AVANTI >", "NEXT >") waitForAny(10_000, "NON IN ELENCO", "UNLISTED")
runCatching { tapAny("NON IN ELENCO", "UNLISTED") }
val onNetworkStep = {
hasAny("03 · Test rete", "03 · Network test", "AVVIA TEST RETE", "START NETWORK TEST")
}
repeat(4) {
if (onNetworkStep()) return@repeat
scrollDown()
runCatching { tapAny("AVANTI >", "NEXT >") }
SystemClock.sleep(1_200)
if (onNetworkStep()) return@repeat
scrollUp()
SystemClock.sleep(800)
}
waitForAny(45_000, "03 · Test rete", "03 · Network test") waitForAny(45_000, "03 · Test rete", "03 · Network test")
waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST") waitForAny(30_000, "AVVIA TEST RETE", "START NETWORK TEST")
tapAny("AVVIA TEST RETE", "START NETWORK TEST") tapAny("AVVIA TEST RETE", "START NETWORK TEST")
@@ -262,4 +275,15 @@ class LocalAdaptiveBitrateUiTest {
SystemClock.sleep(300) SystemClock.sleep(300)
} }
} }
private fun scrollUp(steps: Int = 1) {
val centerX = device.displayWidth / 2
val startY = (device.displayHeight * 0.35).toInt()
val endY = (device.displayHeight * 0.75).toInt()
repeat(steps) {
device.swipe(centerX, startY, centerX, endY, 24)
device.waitForIdle()
SystemClock.sleep(300)
}
}
} }
@@ -28,7 +28,7 @@ class ReleaseApiSmokeTest {
fun login_parsesResponse() = runBlocking { fun login_parsesResponse() = runBlocking {
val session = container.authRepository.login( val session = container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
assertEquals("coach@matchlivetv.test", session.user.email) assertEquals("coach@matchlivetv.test", session.user.email)
assertTrue(session.accessToken.isNotBlank()) assertTrue(session.accessToken.isNotBlank())
@@ -38,7 +38,7 @@ class ReleaseApiSmokeTest {
fun fetchMatches_afterLogin() = runBlocking { fun fetchMatches_afterLogin() = runBlocking {
container.authRepository.login( container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
val matches = container.matchRepository.fetchMatches() val matches = container.matchRepository.fetchMatches()
assertTrue(matches.isNotEmpty()) assertTrue(matches.isNotEmpty())
@@ -48,15 +48,22 @@ class ReleaseApiSmokeTest {
fun scheduledMatch_parsesAndIsVisible() = runBlocking { fun scheduledMatch_parsesAndIsVisible() = runBlocking {
container.authRepository.login( container.authRepository.login(
email = "coach@matchlivetv.test", email = "coach@matchlivetv.test",
password = "password123", password = "Password123",
) )
val teams = container.matchRepository.fetchTeams() val teams = container.matchRepository.fetchTeams()
val tigers = teams.first { it.name == "Tigers Volley" } assertTrue(teams.isNotEmpty())
val raw = container.api.matches(tigers.id) var scheduled: com.matchlivetv.match_live_tv.data.api.MatchDto? = null
val scheduled = raw.first { it.opponentName.contains("Crazy Volley") } for (team in teams) {
assertNotNull(scheduled.scheduledAt) val found = container.api.matches(team.id).firstOrNull { !it.scheduledAt.isNullOrBlank() }
assertNotNull(parseApiInstant(scheduled.scheduledAt)) if (found != null) {
val domain = scheduled.toDomain() scheduled = found
break
}
}
val match = checkNotNull(scheduled) { "Nessuna partita con scheduled_at tra i team del coach" }
assertNotNull(match.scheduledAt)
assertNotNull(parseApiInstant(match.scheduledAt))
val domain = match.toDomain()
assertTrue(domain.isCoachHubVisible()) assertTrue(domain.isCoachHubVisible())
} }
} }
@@ -3,9 +3,13 @@ package com.matchlivetv.match_live_tv.core
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.BatteryManager import android.os.BatteryManager
import android.os.Build
import android.telephony.TelephonyManager
import com.matchlivetv.match_live_tv.data.api.ClientInfoPayload
data class DeviceHealthSnapshot( data class DeviceHealthSnapshot(
val batteryPercent: Int, val batteryPercent: Int,
@@ -27,6 +31,38 @@ object DeviceTelemetry {
} }
}.getOrDefault("Sconosciuto") }.getOrDefault("Sconosciuto")
fun clientInfo(context: Context): ClientInfoPayload {
val packageInfo = runCatching {
if (Build.VERSION.SDK_INT >= 33) {
context.packageManager.getPackageInfo(
context.packageName,
PackageManager.PackageInfoFlags.of(0),
)
} else {
@Suppress("DEPRECATION")
context.packageManager.getPackageInfo(context.packageName, 0)
}
}.getOrNull()
val versionName = packageInfo?.versionName
val versionCode = packageInfo?.let {
if (Build.VERSION.SDK_INT >= 28) it.longVersionCode.toString() else {
@Suppress("DEPRECATION")
it.versionCode.toString()
}
}
return ClientInfoPayload(
os = "android",
appVersion = versionName,
appBuild = versionCode,
deviceManufacturer = Build.MANUFACTURER?.takeIf { it.isNotBlank() },
deviceModel = Build.MODEL?.takeIf { it.isNotBlank() },
osVersion = Build.VERSION.RELEASE,
carrier = carrierName(context),
)
}
fun snapshot(context: Context, thermalState: ThermalState? = null): DeviceHealthSnapshot { fun snapshot(context: Context, thermalState: ThermalState? = null): DeviceHealthSnapshot {
val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) val batteryIntent = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
val batteryPercent = readBatteryPercent(batteryIntent) val batteryPercent = readBatteryPercent(batteryIntent)
@@ -37,6 +73,13 @@ object DeviceTelemetry {
) )
} }
private fun carrierName(context: Context): String? = runCatching {
val tm = context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager ?: return null
sequenceOf(tm.networkOperatorName, tm.simOperatorName)
.mapNotNull { it?.trim()?.takeIf { name -> name.isNotEmpty() } }
.firstOrNull()
}.getOrNull()
private fun readBatteryPercent(intent: Intent?): Int { private fun readBatteryPercent(intent: Intent?): Int {
if (intent == null) return 100 if (intent == null) return 100
val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) val level = intent.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
@@ -96,7 +96,7 @@ class AppContainer(context: Context) {
.filter { it.id !in dismissed } .filter { it.id !in dismissed }
} }
val sessionRepository = SessionRepository(api) val sessionRepository = SessionRepository(api, appContext)
val scoreRepository = ScoreRepository(api) val scoreRepository = ScoreRepository(api)
@@ -27,7 +27,10 @@ data class ChangePasswordRequest(
data class MessageResponse(val message: String) data class MessageResponse(val message: String)
data class ApiErrorResponse(val error: String? = null) data class ApiErrorResponse(
val error: String? = null,
@Json(name = "error_code") val errorCode: String? = null,
)
data class LoginResponse( data class LoginResponse(
val user: UserDto, val user: UserDto,
@@ -295,6 +298,17 @@ data class CreateSessionRequest(
@Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000, @Json(name = "target_bitrate") val targetBitrate: Int = 2_500_000,
@Json(name = "target_fps") val targetFps: Int = 30, @Json(name = "target_fps") val targetFps: Int = 30,
@Json(name = "youtube_channel") val youtubeChannel: String? = null, @Json(name = "youtube_channel") val youtubeChannel: String? = null,
val client: ClientInfoPayload? = null,
)
data class ClientInfoPayload(
val os: String,
@Json(name = "app_version") val appVersion: String? = null,
@Json(name = "app_build") val appBuild: String? = null,
@Json(name = "device_manufacturer") val deviceManufacturer: String? = null,
@Json(name = "device_model") val deviceModel: String? = null,
@Json(name = "os_version") val osVersion: String? = null,
val carrier: String? = null,
) )
data class MinQualityRequest( data class MinQualityRequest(
@@ -413,6 +427,7 @@ data class TelemetryRequest(
@Json(name = "target_bitrate") val targetBitrate: Int? = null, @Json(name = "target_bitrate") val targetBitrate: Int? = null,
val fps: Int? = null, val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null, @Json(name = "thermal_state") val thermalState: String? = null,
val client: ClientInfoPayload? = null,
) )
data class AnnouncementDto( data class AnnouncementDto(
@@ -1,28 +1,64 @@
package com.matchlivetv.match_live_tv.data.repository package com.matchlivetv.match_live_tv.data.repository
import android.content.Context
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
import com.matchlivetv.match_live_tv.data.api.ApiErrorResponse
import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest import com.matchlivetv.match_live_tv.data.api.CreateSessionRequest
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.api.MinQualityRequest import com.matchlivetv.match_live_tv.data.api.MinQualityRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest import com.matchlivetv.match_live_tv.data.api.NetworkTestRequest
import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse import com.matchlivetv.match_live_tv.data.api.NetworkTestResponse
import com.matchlivetv.match_live_tv.domain.StreamSession import com.matchlivetv.match_live_tv.domain.StreamSession
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.delay
import retrofit2.HttpException
class SessionRepository( class SessionRepository(
private val api: MatchLiveApi, private val api: MatchLiveApi,
private val appContext: Context,
) { ) {
private val errorAdapter by lazy {
Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
.adapter(ApiErrorResponse::class.java)
}
suspend fun createSession( suspend fun createSession(
matchId: String, matchId: String,
platform: String = "matchlivetv", platform: String = "matchlivetv",
privacyStatus: String = "public", privacyStatus: String = "public",
youtubeChannel: String? = null, youtubeChannel: String? = null,
): StreamSession = api.createSession( scalingRetries: Int = 8,
matchId, scalingRetryDelayMs: Long = 15_000L,
CreateSessionRequest( ): StreamSession {
platform = platform, var attempt = 0
privacyStatus = privacyStatus, while (true) {
youtubeChannel = youtubeChannel, try {
), return api.createSession(
).toDomain() matchId,
CreateSessionRequest(
platform = platform,
privacyStatus = privacyStatus,
youtubeChannel = youtubeChannel,
client = DeviceTelemetry.clientInfo(appContext),
),
).toDomain()
} catch (e: HttpException) {
val code = parseErrorCode(e)
if (code != "stream_capacity_scaling" || attempt >= scalingRetries) throw e
attempt += 1
delay(scalingRetryDelayMs)
}
}
}
private fun parseErrorCode(error: HttpException): String? {
val body = error.response()?.errorBody()?.string().orEmpty()
if (body.isBlank()) return null
return runCatching { errorAdapter.fromJson(body)?.errorCode }.getOrNull()
}
suspend fun fetchSession(sessionId: String): StreamSession = suspend fun fetchSession(sessionId: String): StreamSession =
api.session(sessionId).toDomain() api.session(sessionId).toDomain()
@@ -81,6 +117,7 @@ class SessionRepository(
targetBitrate = targetBitrate, targetBitrate = targetBitrate,
fps = fps, fps = fps,
thermalState = thermalState, thermalState = thermalState,
client = DeviceTelemetry.clientInfo(appContext),
), ),
) )
} }
@@ -1,6 +1,7 @@
import Foundation import Foundation
import UIKit import UIKit
import Network import Network
import CoreTelephony
struct DeviceHealth: Sendable { struct DeviceHealth: Sendable {
let batteryPercent: Int let batteryPercent: Int
@@ -8,6 +9,16 @@ struct DeviceHealth: Sendable {
let networkType: String let networkType: String
} }
struct ClientInfoPayload: Encodable, Sendable {
let os: String
let appVersion: String?
let appBuild: String?
let deviceManufacturer: String?
let deviceModel: String?
let osVersion: String?
let carrier: String?
}
enum DeviceTelemetry { enum DeviceTelemetry {
static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth { static func snapshot(thermalState: ThermalState? = nil) -> DeviceHealth {
UIDevice.current.isBatteryMonitoringEnabled = true UIDevice.current.isBatteryMonitoringEnabled = true
@@ -20,6 +31,24 @@ enum DeviceTelemetry {
) )
} }
static func clientInfo() -> ClientInfoPayload {
let bundle = Bundle.main
let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String
let model = UIDevice.current.model
// Prefer machine identifier when available (e.g. iPhone15,2)
let machine = utsnameMachine()
return ClientInfoPayload(
os: "ios",
appVersion: version,
appBuild: build,
deviceManufacturer: "Apple",
deviceModel: machine ?? model,
osVersion: UIDevice.current.systemVersion,
carrier: carrierName()
)
}
private static func currentNetworkType() -> String { private static func currentNetworkType() -> String {
let monitor = NWPathMonitor() let monitor = NWPathMonitor()
let semaphore = DispatchSemaphore(value: 0) let semaphore = DispatchSemaphore(value: 0)
@@ -40,4 +69,27 @@ enum DeviceTelemetry {
monitor.cancel() monitor.cancel()
return result return result
} }
private static func carrierName() -> String? {
let info = CTTelephonyNetworkInfo()
if let providers = info.serviceSubscriberCellularProviders {
for carrier in providers.values {
if let name = carrier.carrierName?.trimmingCharacters(in: .whitespacesAndNewlines),
!name.isEmpty {
return name
}
}
}
return nil
}
private static func utsnameMachine() -> String? {
var systemInfo = utsname()
uname(&systemInfo)
return withUnsafePointer(to: &systemInfo.machine) {
$0.withMemoryRebound(to: CChar.self, capacity: 1) {
String(validatingUTF8: $0)
}
}
}
} }
@@ -435,6 +435,7 @@ struct CreateSessionRequest: Encodable {
let targetBitrate: Int let targetBitrate: Int
let targetFps: Int let targetFps: Int
let youtubeChannel: String? let youtubeChannel: String?
let client: ClientInfoPayload?
} }
struct AudioMuteRequest: Encodable { struct AudioMuteRequest: Encodable {
@@ -531,6 +532,7 @@ struct TelemetryRequest: Encodable {
let targetBitrate: Int? let targetBitrate: Int?
let fps: Int? let fps: Int?
let thermalState: String? let thermalState: String?
let client: ClientInfoPayload?
} }
extension ScoringRules { extension ScoringRules {
@@ -25,7 +25,8 @@ final class SessionRepository {
qualityPreset: qualityPreset, qualityPreset: qualityPreset,
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
targetFps: targetFps, targetFps: targetFps,
youtubeChannel: youtubeChannel youtubeChannel: youtubeChannel,
client: DeviceTelemetry.clientInfo()
) )
).toDomain() ).toDomain()
} }
@@ -97,7 +98,8 @@ final class SessionRepository {
currentBitrate: currentBitrate, currentBitrate: currentBitrate,
targetBitrate: targetBitrate, targetBitrate: targetBitrate,
fps: fps, fps: fps,
thermalState: health.thermalState.apiValue thermalState: health.thermalState.apiValue,
client: DeviceTelemetry.clientInfo()
) )
) )
} }
+2
View File
@@ -23,6 +23,8 @@ STREAM_NODE_AGENT_SECRET=${SECRET}
STREAM_NODE_AGENT_LISTEN=0.0.0.0:9100 STREAM_NODE_AGENT_LISTEN=0.0.0.0:9100
STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888 STREAM_NODE_LOCAL_HLS_URL=http://127.0.0.1:8888
STREAM_NODE_RELAY_LOG_DIR=/var/log STREAM_NODE_RELAY_LOG_DIR=/var/log
STREAM_NODE_SLATES_DIR=/slates/custom
STREAM_NODE_RECORDINGS_DIR=/recordings
EOF EOF
chmod 600 /opt/stream-node/agent.env" chmod 600 /opt/stream-node/agent.env"
"${SSH[@]}" "cat >/etc/systemd/system/mltv-relay-agent.service <<'EOF' "${SSH[@]}" "cat >/etc/systemd/system/mltv-relay-agent.service <<'EOF'