diff --git a/backend/app/controllers/admin/clubs_controller.rb b/backend/app/controllers/admin/clubs_controller.rb index 775c8da..b39e06d 100644 --- a/backend/app/controllers/admin/clubs_controller.rb +++ b/backend/app/controllers/admin/clubs_controller.rb @@ -12,6 +12,7 @@ module Admin @plans = Plan.ordered.reject { |p| p.slug == "free" } @teams = @club.teams.order(:name) @quote = @club.active_billing_quote + @concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20) end def grant_comped diff --git a/backend/app/controllers/admin/dashboard_controller.rb b/backend/app/controllers/admin/dashboard_controller.rb index f23f76c..95d43cf 100644 --- a/backend/app/controllers/admin/dashboard_controller.rb +++ b/backend/app/controllers/admin/dashboard_controller.rb @@ -8,6 +8,8 @@ module Admin .includes(:stream_node, match: :team) .order(started_at: :desc) @teams = Team.includes(:matches).order(:name).limit(8) + @recent_concurrency_violations = StreamConcurrencyViolation.recent.limit(8) + @concurrency_violation_lookback = StreamConcurrencyViolation.lookback.count end def metrics diff --git a/backend/app/controllers/admin/sessions_controller.rb b/backend/app/controllers/admin/sessions_controller.rb index 3ed3c89..b2cfecd 100644 --- a/backend/app/controllers/admin/sessions_controller.rb +++ b/backend/app/controllers/admin/sessions_controller.rb @@ -18,6 +18,7 @@ module Admin .find(params[:id]) @events = @session.stream_events.recent.limit(100) @club = @session.match.team.club + @concurrency_violations = StreamConcurrencyViolation.for_session(@session.id).recent.limit(20) end def stop diff --git a/backend/app/controllers/admin/stream_concurrency_violations_controller.rb b/backend/app/controllers/admin/stream_concurrency_violations_controller.rb new file mode 100644 index 0000000..02832f0 --- /dev/null +++ b/backend/app/controllers/admin/stream_concurrency_violations_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Admin + class StreamConcurrencyViolationsController < Admin::BaseController + def index + @filters = { + q: params[:q].to_s.strip.presence, + devices_differ: params[:devices_differ].to_s == "1" + } + scope = StreamConcurrencyViolation.recent + if @filters[:q] + term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%" + scope = scope.where( + "user_email ILIKE :term OR user_name ILIKE :term OR occupying_club_name ILIKE :term OR attempted_club_name ILIKE :term OR occupying_match_label ILIKE :term OR attempted_match_label ILIKE :term", + term: term + ) + end + scope = scope.two_devices if @filters[:devices_differ] + @total_count = scope.count + @violations = scope.limit(200) + @lookback_count = StreamConcurrencyViolation.lookback.count + @two_devices_count = StreamConcurrencyViolation.lookback.two_devices.count + end + end +end diff --git a/backend/app/helpers/admin_helper.rb b/backend/app/helpers/admin_helper.rb index a260730..5af30f2 100644 --- a/backend/app/helpers/admin_helper.rb +++ b/backend/app/helpers/admin_helper.rb @@ -188,4 +188,8 @@ module AdminHelper labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") } labels.presence&.join(" · ") || I18n.t("admin.common.dash") end + + def admin_concurrency_violation_lookback_count + @admin_concurrency_violation_lookback_count ||= StreamConcurrencyViolation.lookback.count + end end diff --git a/backend/app/models/stream_concurrency_violation.rb b/backend/app/models/stream_concurrency_violation.rb new file mode 100644 index 0000000..75fcb6a --- /dev/null +++ b/backend/app/models/stream_concurrency_violation.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class StreamConcurrencyViolation < ApplicationRecord + LOOKBACK = 30.days + + belongs_to :user, optional: true + belongs_to :occupying_session, class_name: "StreamSession", optional: true + belongs_to :attempted_session, class_name: "StreamSession", optional: true + belongs_to :occupying_club, class_name: "Club", optional: true + belongs_to :attempted_club, class_name: "Club", optional: true + + validates :user_email, :occupying_match_label, :attempted_match_label, presence: true + validates :attempt_action, inclusion: { in: %w[start resume] } + + scope :recent, -> { order(created_at: :desc) } + scope :since, ->(time) { where("created_at >= ?", time) } + scope :lookback, -> { since(LOOKBACK.ago) } + scope :two_devices, -> { where(devices_differ: true) } + scope :for_club, lambda { |club_id| + where("occupying_club_id = :id OR attempted_club_id = :id", id: club_id) + } + scope :for_session, lambda { |session_id| + where("occupying_session_id = :id OR attempted_session_id = :id", id: session_id) + } + + def self.record!(attempted:, occupying:, action: "start") + user = attempted.user || occupying.user + occupying_club = occupying.match&.team&.club + attempted_club = attempted.match&.team&.club + occupying_device = occupying.client_device_label + attempted_device = attempted.client_device_label + + create!( + user: user, + occupying_session: occupying, + attempted_session: attempted, + occupying_club: occupying_club, + attempted_club: attempted_club, + attempt_action: action.to_s, + user_email: user&.email.presence || "unknown", + user_name: user&.name, + occupying_club_name: occupying_club&.name, + attempted_club_name: attempted_club&.name, + occupying_match_label: occupying.match_label, + attempted_match_label: attempted.match_label, + occupying_status: occupying.status, + occupying_device: occupying_device, + attempted_device: attempted_device, + devices_differ: StreamSession.devices_differ?(occupying, attempted), + metadata: { + occupying_session_id: occupying.id, + attempted_session_id: attempted.id, + occupying_client_os: occupying.client_os, + attempted_client_os: attempted.client_os, + occupying_app_version: occupying.app_version, + attempted_app_version: attempted.app_version + }.compact + ) + rescue StandardError => e + Rails.logger.error("[StreamConcurrencyViolation] record failed: #{e.class} #{e.message}") + nil + end + + def club_name + attempted_club_name.presence || occupying_club_name + end + + def operator_label + [user_name.presence, user_email].compact.join(" · ") + end +end diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb index 1181dcc..ebb7cd2 100644 --- a/backend/app/models/stream_session.rb +++ b/backend/app/models/stream_session.rb @@ -10,6 +10,10 @@ class StreamSession < ApplicationRecord belongs_to :user belongs_to :stream_node, optional: true has_many :stream_events, dependent: :destroy + has_many :occupying_concurrency_violations, class_name: "StreamConcurrencyViolation", + foreign_key: :occupying_session_id, dependent: :nullify, inverse_of: :occupying_session + has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation", + foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session has_one :score_state, dependent: :destroy has_one :recording has_many :device_states, dependent: :destroy @@ -84,6 +88,35 @@ class StreamSession < ApplicationRecord stream_node&.role.presence || ingest_role end + def match_label + team_name = match&.team&.name + opponent = match&.opponent_name + return id.to_s if team_name.blank? + + opponent.present? ? "#{team_name} vs #{opponent}" : team_name + end + + def client_device_label + parts = [] + parts << client_os if client_os.present? + device = [device_manufacturer, device_model].compact_blank.join(" ") + parts << device if device.present? + parts << "OS #{os_version}" if os_version.present? + parts.join(" · ").presence + end + + def client_device_key + [client_os, device_manufacturer, device_model].map { |v| v.to_s.strip.downcase }.join("|") + end + + def self.devices_differ?(left, right) + ka = left.client_device_key + kb = right.client_device_key + return false if ka.delete("|").blank? || kb.delete("|").blank? + + ka != kb + end + def rtmp_ingest_url # RootEncoder richiede rtmp://host:port/app/stream (due segmenti). # MediaMTX path = live/match_{uuid} (no ?token= nel path). diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index bb3adc6..5979013 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -11,6 +11,7 @@ class User < ApplicationRecord has_many :clubs, through: :club_memberships has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club has_many :stream_sessions, dependent: :nullify + has_many :stream_concurrency_violations, dependent: :nullify def manageable_teams staff_ids = teams.select(:id) diff --git a/backend/app/services/sessions/assert_user_concurrent.rb b/backend/app/services/sessions/assert_user_concurrent.rb new file mode 100644 index 0000000..cccb0b3 --- /dev/null +++ b/backend/app/services/sessions/assert_user_concurrent.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Sessions + class AssertUserConcurrent + ERROR_CODE = "user_concurrent_stream" + + def self.with_lock(session, action: "start") + new(session, action: action).with_lock { yield } + end + + def initialize(session, action: "start") + @session = session + @action = action.to_s + end + + def with_lock + occupying = nil + + User.transaction do + User.lock.find(@session.user_id) if @session.user_id.present? + occupying = occupying_session + yield if occupying.nil? + end + + return if occupying.nil? + + StreamConcurrencyViolation.record!( + attempted: @session, + occupying: occupying, + action: @action + ) + raise Teams::EntitlementError.new( + I18n.t("api.errors.user_concurrent_stream"), + code: ERROR_CODE + ) + end + + private + + def occupying_session + return if @session.user_id.blank? + + StreamSession.broadcasting + .where(user_id: @session.user_id) + .where.not(id: @session.id) + .includes(:user, match: { team: :club }) + .order(Arel.sql("COALESCE(started_at, updated_at) DESC")) + .first + end + end +end diff --git a/backend/app/services/sessions/resume.rb b/backend/app/services/sessions/resume.rb index 2da9fee..26d0661 100644 --- a/backend/app/services/sessions/resume.rb +++ b/backend/app/services/sessions/resume.rb @@ -10,8 +10,10 @@ module Sessions end cancel_timeout_job - # connecting finché RTMP non è online (evita lose_connection da PublisherSync) - @session.begin_connect! if @session.may_begin_connect? + Sessions::AssertUserConcurrent.with_lock(@session, action: "resume") do + # connecting finché RTMP non è online (evita lose_connection da PublisherSync) + @session.begin_connect! if @session.may_begin_connect? + end # Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher). log_event("resumed") SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" }) diff --git a/backend/app/services/sessions/start.rb b/backend/app/services/sessions/start.rb index 583a5e5..36011ac 100644 --- a/backend/app/services/sessions/start.rb +++ b/backend/app/services/sessions/start.rb @@ -5,9 +5,11 @@ module Sessions end def call - @session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session) - @session.begin_connect! if @session.may_begin_connect? - @session.update!(status: "connecting") unless @session.connecting? + Sessions::AssertUserConcurrent.with_lock(@session, action: "start") do + @session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session) + @session.begin_connect! if @session.may_begin_connect? + @session.update!(status: "connecting") unless @session.connecting? + end Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube" broadcast_status("connecting") @session diff --git a/backend/app/views/admin/clubs/show.html.erb b/backend/app/views/admin/clubs/show.html.erb index 12eda8c..d048d8e 100644 --- a/backend/app/views/admin/clubs/show.html.erb +++ b/backend/app/views/admin/clubs/show.html.erb @@ -46,3 +46,14 @@ <% end %> + +

<%= t("admin.clubs.show.concurrency_title") %>

+

<%= t("admin.clubs.show.concurrency_lead") %>

+<%= render "admin/stream_concurrency_violations/table", + violations: @concurrency_violations, + empty_key: "admin.clubs.show.concurrency_none" %> +<% if @concurrency_violations.any? %> +

+ <%= link_to t("admin.clubs.show.concurrency_all"), admin_stream_concurrency_violations_path(q: @club.name) %> +

+<% end %> diff --git a/backend/app/views/admin/dashboard/index.html.erb b/backend/app/views/admin/dashboard/index.html.erb index 0bd4412..895ca50 100644 --- a/backend/app/views/admin/dashboard/index.html.erb +++ b/backend/app/views/admin/dashboard/index.html.erb @@ -67,6 +67,24 @@ <% end %> +
+

<%= t("admin.dashboard.concurrency_panel.title") %>

+ <% if @concurrency_violation_lookback.positive? %> +

+ <%= t("admin.dashboard.concurrency_panel.count", count: @concurrency_violation_lookback) %> + — <%= link_to t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path %> +

+ <%= render "admin/stream_concurrency_violations/table", + violations: @recent_concurrency_violations, + empty_key: "admin.dashboard.concurrency_panel.none" %> + <% else %> +

+ <%= t("admin.dashboard.concurrency_panel.none_html", + link: link_to(t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path)) %> +

+ <% end %> +
+

<%= t("admin.dashboard.disk.system_title") %>

diff --git a/backend/app/views/admin/sessions/show.html.erb b/backend/app/views/admin/sessions/show.html.erb index 70eedfa..10d9f54 100644 --- a/backend/app/views/admin/sessions/show.html.erb +++ b/backend/app/views/admin/sessions/show.html.erb @@ -258,6 +258,16 @@
<% end %> +<% if @concurrency_violations.any? %> +
+

<%= t("admin.sessions.show.concurrency_title") %>

+

<%= t("admin.sessions.show.concurrency_lead") %>

+ <%= render "admin/stream_concurrency_violations/table", + violations: @concurrency_violations, + empty_key: "admin.sessions.show.concurrency_none" %> +
+<% end %> +

<%= t("admin.sessions.show.events_title") %>

<% if @events.any? %> diff --git a/backend/app/views/admin/stream_concurrency_violations/_table.html.erb b/backend/app/views/admin/stream_concurrency_violations/_table.html.erb new file mode 100644 index 0000000..47813a6 --- /dev/null +++ b/backend/app/views/admin/stream_concurrency_violations/_table.html.erb @@ -0,0 +1,67 @@ +<% if violations.any? %> +
+ + + + + + + + + + + + + <% violations.each do |row| %> + + + + + + + + + <% end %> + +
<%= t("admin.stream_concurrency.table.when") %><%= t("admin.stream_concurrency.table.account") %><%= t("admin.stream_concurrency.table.club") %><%= t("admin.stream_concurrency.table.occupying") %><%= t("admin.stream_concurrency.table.attempted") %>
<%= admin_datetime(row.created_at, with_seconds: true) %> + <%= row.user_name.presence || t("admin.common.dash") %> +
<%= row.user_email %>
+
+ <% if row.attempted_club %> + <%= link_to row.club_name, admin_club_path(row.attempted_club) %> + <% elsif row.occupying_club %> + <%= link_to row.club_name, admin_club_path(row.occupying_club) %> + <% else %> + <%= row.club_name.presence || t("admin.common.dash") %> + <% end %> + + <% if row.occupying_session %> + <%= link_to row.occupying_match_label, admin_session_path(row.occupying_session) %> + <% else %> + <%= row.occupying_match_label %> + <% end %> +
+ <%= row.occupying_status %> + <%= row.occupying_device.presence || t("admin.common.dash") %> +
+
+ <% if row.attempted_session %> + <%= link_to row.attempted_match_label, admin_session_path(row.attempted_session) %> + <% else %> + <%= row.attempted_match_label %> + <% end %> +
+ <%= t("admin.stream_concurrency.action.#{row.attempt_action}") %> + · <%= row.attempted_device.presence || t("admin.common.dash") %> +
+
+ <% if row.devices_differ? %> + <%= t("admin.stream_concurrency.badge.two_devices") %> + <% else %> + <%= t("admin.stream_concurrency.badge.same_or_unknown") %> + <% end %> +
+
+<% else %> +

<%= t(empty_key) %>

+<% end %> diff --git a/backend/app/views/admin/stream_concurrency_violations/index.html.erb b/backend/app/views/admin/stream_concurrency_violations/index.html.erb new file mode 100644 index 0000000..6acade3 --- /dev/null +++ b/backend/app/views/admin/stream_concurrency_violations/index.html.erb @@ -0,0 +1,48 @@ +<% content_for :body_class, "admin-body" %> + +
+

<%= t("admin.stream_concurrency.index.title") %>

+

<%= t("admin.stream_concurrency.index.lead") %>

+
+ +
+
+
<%= t("admin.stream_concurrency.kpi.lookback") %>
+
<%= @lookback_count %>
+
<%= t("admin.stream_concurrency.kpi.lookback_sub") %>
+
+
+
<%= t("admin.stream_concurrency.kpi.two_devices") %>
+
<%= @two_devices_count %>
+
<%= t("admin.stream_concurrency.kpi.two_devices_sub") %>
+
+
+ +
+ <%= form_with url: admin_stream_concurrency_violations_path, method: :get, local: true, class: "admin-filter-form" do %> +
+ + +
+
+ <%= submit_tag t("admin.stream_concurrency.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %> + <%= link_to t("admin.stream_concurrency.filters.reset"), admin_stream_concurrency_violations_path, class: "admin-btn admin-btn--outline admin-btn--sm" %> + + <%= t("admin.stream_concurrency.index.results", shown: @violations.size, total: @total_count) %> + +
+ <% end %> +
+ +
+ <%= render "admin/stream_concurrency_violations/table", violations: @violations, empty_key: "admin.stream_concurrency.index.none" %> +
diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb index fc618e0..6b1b729 100644 --- a/backend/app/views/layouts/admin.html.erb +++ b/backend/app/views/layouts/admin.html.erb @@ -5,7 +5,7 @@ <%= csrf_meta_tags %> - + <%= yield :head %> <% if content_for?(:replay_archive_styles) %> @@ -32,6 +32,10 @@ <%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %> <%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %> <%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %> + <% abuse_count = admin_concurrency_violation_lookback_count %> + <%= link_to admin_stream_concurrency_violations_path, class: ("active" if controller_name == "stream_concurrency_violations") do %> + <%= t("admin.layout.nav.stream_concurrency") %><% if abuse_count.positive? %> <%= abuse_count %><% end %> + <% end %> <%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %> <%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %> <%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %> diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index c2ec624..c417a68 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -10,7 +10,7 @@ <%= render "shared/analytics_suppress" %> <%= yield :head %> - + data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <%= render "shared/cookie_banner" %> diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index c56588c..dc259fe 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -8,7 +8,7 @@ <%= render "shared/meta_tags" %> <%= render "shared/analytics_suppress" %> - + <%= yield :head %> diff --git a/backend/app/views/public/live/index.html.erb b/backend/app/views/public/live/index.html.erb index e6ff744..dea1b8d 100644 --- a/backend/app/views/public/live/index.html.erb +++ b/backend/app/views/public/live/index.html.erb @@ -164,8 +164,8 @@