Aggiunge copertina sponsor custom solo Premium Full, con override in wizard e slate sui nodi di streaming.

Permette upload da portale e app con ereditarietà partita→squadra→società, generazione MP4 via ffmpeg e distribuzione su home lab e nodi CPX per pause e assenza segnale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-19 23:47:17 +02:00
co-authored by Cursor
parent a51d5e8da5
commit 41d4235258
62 changed files with 1487 additions and 20 deletions
@@ -0,0 +1,50 @@
require "rails_helper"
RSpec.describe Mediamtx::Client do
let!(:user) { User.create!(email: "mtx@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
let!(:node) do
StreamNode.create!(
slug: "stream-home",
hostname: "home",
role: "home",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://localhost:1935",
hls_base_url: "http://localhost:8888",
api_base_url: "http://mediamtx:9997",
max_publishers: 6,
max_relays: 6
)
end
let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle", stream_node: node)
end
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
end
it "usa SlateDistributor per alwaysAvailableFile" do
custom = "/slates/custom/club-test.mp4"
allow(Streams::SlateDistributor).to receive(:slate_path_for_session).with(session).and_return(custom)
conn = instance_double(Faraday::Connection)
response = instance_double(Faraday::Response, success?: true, status: 200, body: {})
allow(Faraday).to receive(:new).and_return(conn)
allow(conn).to receive(:post).and_return(response)
allow(conn).to receive(:get)
allow(Redis).to receive(:new).and_return(instance_double(Redis, get: nil, set: true))
client = described_class.new(base_url: "http://mediamtx:9997")
client.create_path(session)
expect(conn).to have_received(:post).with(
"/v3/config/paths/add/live%2Fmatch_#{session.id}",
hash_including(alwaysAvailableFile: custom)
)
end
end
@@ -0,0 +1,71 @@
require "rails_helper"
RSpec.describe Streams::CoverResolver 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!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "free")
end
def attach_ready_cover(record, name)
record.cover_image.attach(
io: StringIO.new("fake-image"),
filename: "#{name}.png",
content_type: "image/png"
)
record.cover_slate.attach(
io: StringIO.new("fake-mp4"),
filename: "#{name}.mp4",
content_type: "video/mp4"
)
path = Streams::CoverSlatePaths.expected_path(record)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, "fake mp4")
path
end
it "restituisce default senza Premium Full" do
resolver = described_class.new(match)
expect(resolver.cover_source).to eq("default")
expect(resolver.effective_cover_url).to eq("/images/copertina-canale.png")
end
context "con Premium Full" do
before { Billing::AssignPlan.call(club: club, plan_slug: "premium_full") }
it "preferisce partita, poi squadra, poi società" do
attach_ready_cover(club, "club")
attach_ready_cover(team, "team")
attach_ready_cover(match, "match")
resolver = described_class.new(match)
expect(resolver.cover_source).to eq("match")
end
it "eredita dalla squadra se la partita non ha override" do
attach_ready_cover(club, "club")
attach_ready_cover(team, "team")
resolver = described_class.new(match)
expect(resolver.cover_source).to eq("team")
end
it "eredita dalla società se mancano squadra e partita" do
attach_ready_cover(club, "club")
resolver = described_class.new(match)
expect(resolver.cover_source).to eq("club")
end
it "ignora cover_image senza slate MP4 pronta su disco" do
attach_ready_cover(team, "team")
match.cover_image.attach(io: StringIO.new("img"), filename: "m.png", content_type: "image/png")
resolver = described_class.new(match)
expect(resolver.cover_source).to eq("team")
end
end
end
@@ -0,0 +1,38 @@
require "rails_helper"
RSpec.describe Streams::CoverSlateEnsurer 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!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
match.cover_image.attach(
io: StringIO.new("img"),
filename: "c.png",
content_type: "image/png"
)
end
it "genera in sync se manca cover_slate" do
expect(Streams::GenerateCoverSlate).to receive(:call).with(match).and_return(match)
described_class.ensure_for!(match)
end
it "non rigenera se slate pronta su disco" do
match.cover_slate.attach(
io: StringIO.new("mp4"),
filename: "s.mp4",
content_type: "video/mp4"
)
path = Streams::CoverSlatePaths.expected_path(match)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, "fake")
expect(Streams::GenerateCoverSlate).not_to receive(:call)
described_class.ensure_for!(match)
ensure
FileUtils.rm_f(path) if defined?(path) && path
end
end
@@ -0,0 +1,50 @@
require "rails_helper"
RSpec.describe Streams::GenerateCoverSlate do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
club.cover_image.attach(
io: StringIO.new("fake-image"),
filename: "cover.png",
content_type: "image/png"
)
end
it "genera cover_slate e scrive su disco se ffmpeg ok" do
mp4 = Tempfile.new(["slate", ".mp4"])
mp4.write("fake mp4")
mp4.flush
allow(Open3).to receive(:capture3).and_return(["", "", instance_double(Process::Status, success?: true)])
allow(File).to receive(:exist?).and_call_original
allow(File).to receive(:exist?).with(mp4.path).and_return(true)
allow(File).to receive(:size).with(mp4.path).and_return(8)
service = described_class.new(club)
allow(service).to receive(:encode_slate).and_return(mp4.path)
allow(service).to receive(:download_cover_image).and_return("/tmp/in.png")
slates_dir = Rails.root.join("tmp/slates-custom-test")
FileUtils.mkdir_p(slates_dir)
allow(Streams::CoverSlatePaths).to receive(:slates_custom_root).and_return(slates_dir.to_s)
service.call
expect(club.reload.cover_slate).to be_attached
expect(club.cover_slate.content_type).to eq("video/mp4")
ensure
mp4.close!
FileUtils.rm_rf(slates_dir) if defined?(slates_dir)
end
it "solleva Error se ffmpeg fallisce" do
allow(Open3).to receive(:capture3).and_return(["", "boom", instance_double(Process::Status, success?: false)])
expect {
described_class.new(club).send(:encode_slate, "/tmp/in.png")
}.to raise_error(Streams::GenerateCoverSlate::Error, /ffmpeg slate failed/)
end
end
@@ -0,0 +1,80 @@
require "rails_helper"
RSpec.describe Streams::SlateDistributor do
let!(:user) { User.create!(email: "u@test.it", name: "U", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") }
let!(:node) do
StreamNode.create!(
slug: "stream-home",
hostname: "home",
role: "home",
status: "ready",
provider: "local",
rtmp_base_url: "rtmp://localhost:1935",
hls_base_url: "http://localhost:8888",
api_base_url: "http://mediamtx:9997",
max_publishers: 6,
max_relays: 6
)
end
let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "idle", stream_node: node)
end
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
end
def attach_ready_cover(record)
record.cover_image.attach(io: StringIO.new("img"), filename: "c.png", content_type: "image/png")
record.cover_slate.attach(io: StringIO.new("mp4"), filename: "s.mp4", content_type: "video/mp4")
path = Streams::CoverSlatePaths.expected_path(record)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, "fake mp4")
path
end
it "usa slate default senza copertina custom" do
expect(described_class.slate_path_for_session(session)).to eq("/slates/offline.mp4")
end
it "usa path custom su nodo home quando la slate è su disco" do
attach_ready_cover(club)
custom = "/slates/custom/#{File.basename(Streams::CoverSlatePaths.expected_path(club))}"
expect(described_class.slate_path_for_session(session)).to eq(custom)
end
it "pusha sul relay agent per nodi cloud" do
cloud = StreamNode.create!(
slug: "ingest-01",
hostname: "ingest-01",
role: "cloud",
status: "ready",
provider: "hetzner",
rtmp_base_url: "rtmp://ingest-01.test:1935",
hls_base_url: "http://localhost:8888",
api_base_url: "http://203.0.113.9:9997",
max_publishers: 4,
max_relays: 4,
metadata: { "public_ip" => "203.0.113.9" }
)
session.update!(stream_node: cloud)
attach_ready_cover(club)
http = instance_double(Net::HTTP)
response = instance_double(Net::HTTPResponse, body: "{}", code: "201")
allow(response).to receive(:is_a?).with(Net::HTTPSuccess).and_return(true)
allow(Net::HTTP).to receive(:new).and_return(http)
allow(http).to receive(:open_timeout=)
allow(http).to receive(:read_timeout=)
allow(http).to receive(:request).and_return(response)
filename = File.basename(Streams::CoverSlatePaths.expected_path(club))
path = described_class.slate_path_for_session(session)
expect(path).to eq("/slates/custom/#{filename}")
expect(http).to have_received(:request)
end
end
@@ -0,0 +1,30 @@
require "rails_helper"
RSpec.describe Teams::Entitlements do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "free")
end
it "non consente copertina custom su free" do
ent = described_class.new(team)
expect(ent.can_use_custom_cover?).to eq(false)
expect { ent.assert_can_upload_cover! }.to raise_error(Teams::EntitlementError, /Premium Full/)
end
it "consente copertina custom su premium full" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
ent = described_class.new(team)
expect(ent.can_use_custom_cover?).to eq(true)
expect { ent.assert_can_upload_cover! }.not_to raise_error
end
it "espone custom_cover_enabled in as_json" do
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
ent = described_class.new(team)
expect(ent.as_json[:custom_cover_enabled]).to eq(true)
end
end