From 4c5af99efa2b186448df0dc0bb1d86c787a1b11e Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Tue, 11 Aug 2026 18:27:13 +0200 Subject: [PATCH] Allinea slate Cloud a AAC 48k mono e preferisce home in allocate. Senza questo il telefono (mono 48k) veniva rifiutato sui nodi Hetzner e lo warm spare rubava le sessioni a home. Co-authored-by: Cursor --- backend/app/services/streams/node_registry.rb | 11 ++++- .../services/streams/node_registry_spec.rb | 42 ++++++++++++++++++- infra/stream-node/cloud-init.yaml | 11 +++-- 3 files changed, 54 insertions(+), 10 deletions(-) diff --git a/backend/app/services/streams/node_registry.rb b/backend/app/services/streams/node_registry.rb index 96705de..2710d32 100644 --- a/backend/app/services/streams/node_registry.rb +++ b/backend/app/services/streams/node_registry.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true module Streams - # Assegna un StreamNode a una nuova sessione (least-loaded tra i ready). + # Assegna un StreamNode a una nuova sessione. + # Preferisce home finché ha slot; poi least-loaded tra i nodi overflow ready. # Garantisce il nodo "home" derivato dagli ENV MediaMTX attuali. class NodeRegistry class NoCapacityError < StandardError; end @@ -33,10 +34,16 @@ module Streams def allocate! ensure_home_from_env! + # Overflow: riempi prima home; i nodi cloud/lab sono solo quando home è pieno + # (altrimenti lo warm spare ruberebbe tutte le sessioni). + home = StreamNode.find_by(slug: HOME_SLUG) + return home if home&.allocatable? + node = StreamNode.ready + .where.not(slug: HOME_SLUG) .to_a .select(&:allocatable?) - .min_by { |n| [n.active_publishers, n.role == "home" ? 1 : 0, n.slug] } + .min_by { |n| [n.active_publishers, n.slug] } raise NoCapacityError, "Nessun nodo streaming con slot liberi" if node.nil? diff --git a/backend/spec/services/streams/node_registry_spec.rb b/backend/spec/services/streams/node_registry_spec.rb index 60df2d3..bb1485d 100644 --- a/backend/spec/services/streams/node_registry_spec.rb +++ b/backend/spec/services/streams/node_registry_spec.rb @@ -49,7 +49,27 @@ RSpec.describe Streams::NodeRegistry do end end - it "allocates the least loaded ready node" do + it "prefers home while it still has free slots even if cloud is idle" do + with_env(home_env) do + home = described_class.ensure_home_from_env! + 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", + max_publishers: 2, + max_relays: 2 + ) + + expect(described_class.allocate!).to eq(home) + end + end + + it "allocates the least loaded cloud node when home is full" do with_env(home_env) do home = described_class.ensure_home_from_env! cloud = StreamNode.create!( @@ -64,14 +84,32 @@ RSpec.describe Streams::NodeRegistry do max_publishers: 2, max_relays: 2 ) + cloud_busy = StreamNode.create!( + slug: "ingest-02", + hostname: "ingest-02.mltv-stream.net", + role: "cloud", + status: "ready", + provider: "hetzner", + rtmp_base_url: "rtmp://ingest-02.mltv-stream.net:1935", + hls_base_url: "https://ingest-02.mltv-stream.net/hls", + api_base_url: "http://10.0.0.3:9997", + max_publishers: 2, + max_relays: 2 + ) user = User.create!(email: "n@example.com", name: "N", password: "Password123", role: "coach") club = Club.create!(name: "C", sport: "volleyball") team = club.teams.create!(name: "T", sport: "volleyball", slug: "t-node") match = team.matches.create!(opponent_name: "X", scheduled_at: 1.hour.from_now) + match2 = team.matches.create!(opponent_name: "Y", scheduled_at: 2.hours.from_now) + # Riempie home (max 2) + StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home) + StreamSession.create!(match: match2, user: user, platform: "matchlivetv", status: "live", stream_node: home) + # Un publisher già su ingest-02 → least-loaded = ingest-01 StreamSession.create!( - match: match, user: user, platform: "matchlivetv", status: "live", stream_node: home + match: team.matches.create!(opponent_name: "Z", scheduled_at: 3.hours.from_now), + user: user, platform: "matchlivetv", status: "live", stream_node: cloud_busy ) expect(described_class.allocate!).to eq(cloud) diff --git a/infra/stream-node/cloud-init.yaml b/infra/stream-node/cloud-init.yaml index d3f5eee..f229444 100644 --- a/infra/stream-node/cloud-init.yaml +++ b/infra/stream-node/cloud-init.yaml @@ -48,13 +48,12 @@ write_files: done docker pull bluenviron/mediamtx:latest docker rm -f mediamtx 2>/dev/null || true - # Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path - # MediaMTX 1.20+: il publisher RTMP deve matchare canali audio della slate (stereo). + # Slate offline (alwaysAvailable) — richiesta da Mediamtx::Client#create_path. + # MediaMTX 1.20+: AAC del publisher RTMP deve matchare la slate. + # Allineato ad app Android (BroadcastConfig: 48 kHz mono) e infra/scripts/generate_slate.sh. mkdir -p /slates - if [ ! -f /slates/offline.mp4 ]; then - ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=44100:cl=stereo \ - -c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -ac 2 -shortest /slates/offline.mp4 - fi + ffmpeg -y -f lavfi -i color=c=black:s=1280x720:d=2 -f lavfi -i anullsrc=r=48000:cl=mono \ + -c:v libx264 -t 2 -pix_fmt yuv420p -c:a aac -ac 1 -ar 48000 -shortest /slates/offline.mp4 # Debian cloud image: docker.io senza apparmor_parser → serve unconfined o pkg apparmor docker run -d --name mediamtx --restart unless-stopped --network host \ --security-opt apparmor=unconfined \