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:
@@ -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
|
||||
Reference in New Issue
Block a user