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