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
@@ -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