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>
This commit is contained in:
2026-08-28 13:01:41 +02:00
co-authored by Cursor
parent 35dfa923e3
commit 05ef56c56d
54 changed files with 1843 additions and 201 deletions
@@ -79,14 +79,21 @@ module Admin
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
scope = scope.where(stream_sessions: { status: @filters[:status] })
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] })
end
if @filters[:club_id].present?
scope = scope.where(teams: { club_id: @filters[:club_id] })
end
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
if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
scope = scope.where(
@@ -97,10 +97,11 @@ module Api
replay_url: recording.replay_url,
playback_url: recording.playback_stream_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_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_label: recording.source_platform_label,
expires_at: recording.expires_at,
@@ -171,12 +171,13 @@ module Api
replay_url: recording.replay_url,
playback_url: recording.playback_stream_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,
views_label: recording.views_label,
youtube_video_id: recording.youtube_video_id,
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_label: recording.source_platform_label,
expires_at: recording.expires_at,
+6 -4
View File
@@ -30,8 +30,9 @@ module AdminHelper
links
end
def admin_session_ingest_badge_class(node)
case node.role
def admin_session_ingest_badge_class(role_or_node)
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 "lab" then "badge--ingest-lab"
when "cloud" then "badge--ingest-cloud"
@@ -39,8 +40,9 @@ module AdminHelper
end
end
def admin_session_ingest_role_label(node)
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize)
def admin_session_ingest_role_label(role_or_node)
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
def admin_session_status_badge_class(status)
@@ -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
class PostProcessJob
include Sidekiq::Job
@@ -7,10 +9,30 @@ module Recordings
recording = Recording.find_by(id: recording_id)
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 if recording.youtube_video_id.present?
return if recording.source_platform == "youtube"
Recordings::PublishToYoutubeJob.perform_async(recording.id)
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
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
return unless redis
@@ -17,6 +19,16 @@ module Streams
perform_in(INTERVAL_SECS)
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
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
+51 -4
View File
@@ -2,6 +2,8 @@ class Recording < ApplicationRecord
STATUSES = %w[processing ready expired failed].freeze
PRIVACY_STATUSES = %w[public unlisted].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 :team
@@ -9,6 +11,7 @@ class Recording < ApplicationRecord
validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
validates :storage_policy, inclusion: { in: STORAGE_POLICIES }
scope :not_deleted, -> { where(deleted_at: nil) }
scope :ready, lambda {
@@ -23,7 +26,16 @@ class Recording < ApplicationRecord
ready.where(expires_at: ..days.days.from_now)
}
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|
q = query.to_s.strip
@@ -46,19 +58,30 @@ class Recording < ApplicationRecord
end
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"
end
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?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
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
return nil unless ready?
return nil unless ready? && storage_key.present?
"/api/v1/recordings/#{id}/download"
end
@@ -70,6 +93,30 @@ class Recording < ApplicationRecord
"https://www.youtube.com/watch?v=#{youtube_video_id}"
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?
status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
end
@@ -154,7 +201,7 @@ class Recording < ApplicationRecord
end
def playable_on_site?
ready? && storage_key.present?
available_in_archive?
end
def days_until_expiry
+16 -1
View File
@@ -1,7 +1,8 @@
class StreamSession < ApplicationRecord
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
PRIVACY_STATUSES = %w[public unlisted private].freeze
@@ -20,6 +21,7 @@ class StreamSession < ApplicationRecord
before_validation :normalize_privacy_status
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 :publicly_listed, -> { where(privacy_status: "public") }
@@ -74,6 +76,14 @@ class StreamSession < ApplicationRecord
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
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
@@ -208,6 +218,11 @@ class StreamSession < ApplicationRecord
self.privacy_status = "unlisted" if privacy_status == "private"
end
def snapshot_ingest_from_node
self.ingest_slug = stream_node.slug
self.ingest_role = stream_node.role
end
def record_ended_timestamps!
now = Time.current
update!(ended_at: now) if ended_at.nil?
@@ -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
class FinalizeSession
def initialize(session)
@@ -5,29 +7,69 @@ module Recordings
end
def call
team = @session.match.team
return unless team.entitlements.can_create_recordings?
policy = Recordings::StoragePolicy.call(@session)
return if policy == Recordings::StoragePolicy::NONE
retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
team = @session.match.team
attrs = attributes_for(policy, team)
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,
status: "processing",
title: default_title,
privacy_status: privacy_from_session,
storage_path: @session.mediamtx_path_name,
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,
metadata: initial_metadata(team)
)
recording.save!
recording
metadata: initial_metadata(policy, team)
}
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
match = @session.match
@@ -38,12 +80,13 @@ module Recordings
@session.privacy_status == "public" ? "public" : "unlisted"
end
def initial_metadata(team)
ent = team.entitlements
def initial_metadata(policy, team)
{
"source_platform" => @session.platform,
"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" => {}
}
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
recording = Recording.find_by(stream_session: @session)
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?
fail_recording!(recording, "Nessun file di registrazione trovato")
cleanup_mediamtx_path
@@ -35,23 +36,52 @@ module Recordings
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_cloud_pull!
cleanup_remote_recordings!
cleanup_mediamtx_path
Recordings::PostProcessJob.perform_async(recording.id)
recording
rescue Error, Recordings::Storage::Error => e
rescue Error, Recordings::Storage::Error, Recordings::PullFromCloudNode::Error => e
recording = Recording.find_by(stream_session: @session)
fail_recording!(recording, e.message) if recording
raise
ensure
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
cleanup_cloud_pull! unless recording_ready?
end
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
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
return [] unless Dir.exist?(base)
scan_recording_files(File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name))
end
def scan_recording_files(base)
return [] if base.blank? || !Dir.exist?(base)
Dir.glob(File.join(base, "**", "*"))
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
@@ -83,7 +113,11 @@ module Recordings
end
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
def cleanup_local_sources(source_files, merged_path)
@@ -100,6 +134,24 @@ module Recordings
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
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)
recording.update!(status: "failed", error_message: message)
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
+25 -1
View File
@@ -55,13 +55,15 @@ module Sessions
)
end
kick_autoscaler_if_soft_limit!
if session.platform == "youtube"
YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel)
end
session
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
@@ -73,6 +75,28 @@ module Sessions
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)
resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel)
if resolver.resolve.blank?
+1 -1
View File
@@ -12,7 +12,7 @@ module Sessions
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
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!
log_event("ended")
@@ -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
@@ -1,8 +1,11 @@
<% node = session.stream_node %>
<% if node %>
<% slug = session.ingest_slug_display %>
<% role = session.ingest_role_display %>
<% if slug.present? %>
<div class="admin-ingest">
<code class="admin-ingest__slug"><%= node.slug %></code>
<span class="badge <%= admin_session_ingest_badge_class(node) %>"><%= admin_session_ingest_role_label(node) %></span>
<code class="admin-ingest__slug"><%= slug %></code>
<% if role.present? %>
<span class="badge <%= admin_session_ingest_badge_class(role) %>"><%= admin_session_ingest_role_label(role) %></span>
<% end %>
</div>
<% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
@@ -26,7 +26,7 @@
<span><%= t("admin.sessions.index.filters.platform") %></span>
<%= select_tag :platform,
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]
) %>
</label>
+14 -6
View File
@@ -162,12 +162,20 @@
<div>
<dt><%= t("admin.sessions.show.fields.node") %></dt>
<dd>
<% if @session.stream_node %>
<code><%= @session.stream_node.slug %></code>
<span class="badge <%= admin_session_ingest_badge_class(@session.stream_node) %>">
<%= admin_session_ingest_role_label(@session.stream_node) %>
</span>
<span class="muted">(<%= @session.stream_node.provider %>)</span>
<% slug = @session.ingest_slug_display %>
<% role = @session.ingest_role_display %>
<% if slug.present? %>
<code><%= slug %></code>
<% if role.present? %>
<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 %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
<% end %>
+29 -21
View File
@@ -14,6 +14,33 @@
<h2><%= t("replay.show.processing_title") %></h2>
<p><%= t("replay.show.processing_body") %></p>
</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? %>
<div class="live-player-wrap">
<video id="replay-player" controls playsinline preload="metadata"
@@ -43,9 +70,9 @@
</p>
<% 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">
<% 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" %>
<% end %>
<% if @recording.youtube_watch_url %>
@@ -53,25 +80,6 @@
<% end %>
</div>
<% 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? %>
<div class="stream-ended" role="status">
<h2><%= t("replay.show.file_missing_title") %></h2>
@@ -158,16 +158,16 @@
<% end %>
<span class="visually-hidden"><%= privacy_label %></span>
<% 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") %>
<% 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" } %>
<% 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") %>
<% end %>
<% 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") %>
<%= button_to paths.destroy.call(rec), method: :delete,
params: filter_params,
@@ -196,6 +196,24 @@ module MatchLiveTv
ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true"
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
ENV.fetch("OPS_HTTP_RAILS_URL", "http://edge/up")
end
+1
View File
@@ -353,6 +353,7 @@ de:
generate_button: Regie-Link erzeugen
ingest:
none: "—"
decommissioned: "(Knoten entfernt)"
role:
home: Home-lab
lab: Lab
+1
View File
@@ -353,6 +353,7 @@ en:
generate_button: Generate control link
ingest:
none: "—"
decommissioned: "(node removed)"
role:
home: Home-lab
lab: Lab
+1
View File
@@ -353,6 +353,7 @@ es:
generate_button: Generar enlace de regie
ingest:
none: "—"
decommissioned: "(nodo eliminado)"
role:
home: Home-lab
lab: Lab
+1
View File
@@ -353,6 +353,7 @@ fr:
generate_button: Générer le lien de régie
ingest:
none: "—"
decommissioned: "(nœud retiré)"
role:
home: Home-lab
lab: Lab
+1
View File
@@ -374,6 +374,7 @@ it:
generate_button: Genera link regia
ingest:
none: "—"
decommissioned: "(nodo rimosso)"
role:
home: Home-lab
lab: Lab
@@ -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
+9 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_26_090000) 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
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -338,10 +338,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_26_090000) do
t.datetime "expiry_warning_sent_at"
t.string "youtube_video_id"
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 ["expires_at"], name: "index_recordings_on_expires_at"
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_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 ["team_id", "status"], name: "index_recordings_on_team_id_and_status"
t.index ["team_id"], name: "index_recordings_on_team_id"
@@ -434,6 +439,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_26_090000) do
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 ["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
+33
View File
@@ -20,4 +20,37 @@ namespace :recordings do
orphan = Mediamtx::CleanupOrphanPaths.new.call
puts "Path MediaMTX orfani rimossi: #{orphan.removed} (saltati: #{orphan.skipped})"
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
@@ -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_internal_rtmp_url).to eq("rtmp://10.0.0.2:1935")
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
@@ -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(session_a.id)
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
@@ -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,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
@@ -25,7 +25,9 @@ RSpec.describe Recordings::FinalizeSession do
rec = described_class.new(session).call
expect(rec.status).to eq("processing")
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.metadata["auto_publish_youtube"]).to eq(false)
end
it "skips free plan" do
@@ -39,6 +41,18 @@ RSpec.describe Recordings::FinalizeSession do
expect(rec.expires_at).to be > 89.days.from_now
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
Billing::AssignPlan.call(club: club, plan_slug: "premium_light")
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,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