Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ef56c56d | ||
|
|
35dfa923e3 | ||
|
|
9d8b35c06c | ||
|
|
873e0ea55c | ||
|
|
4573edfedc | ||
|
|
b301868774 | ||
|
|
b87a0f7bc0 | ||
|
|
70fccc493e | ||
|
|
406a90ae5b | ||
|
|
8e7bbdf8b2 | ||
|
|
b84321346f | ||
|
|
8a07c5d569 | ||
|
|
cc96c0396a | ||
|
|
79c48b226e | ||
|
|
c9b75065bc | ||
|
|
4ca9f3af8b | ||
|
|
60dfe7a75b |
@@ -0,0 +1,22 @@
|
||||
---
|
||||
description: Scalare soft limit ingest/autoscaler man mano che crescono i clienti
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Soft limit stream / autoscaler
|
||||
|
||||
Con la crescita del numero di clienti (e delle dirette concorrenti attese), **alzare i soft limit** prima di restare a corto di capacità.
|
||||
|
||||
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
|
||||
|
||||
| Parametro | Ruolo oggi (post load test 2026-08) |
|
||||
|-----------|--------------------------------------|
|
||||
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) |
|
||||
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) |
|
||||
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) |
|
||||
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
|
||||
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
|
||||
|
||||
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (es. 6+3×4 = **18**).
|
||||
|
||||
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo.
|
||||
@@ -0,0 +1,101 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Admin
|
||||
class AnalyticsController < Admin::BaseController
|
||||
def index
|
||||
@filters = {
|
||||
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
||||
to: parse_date(params[:to]) || Time.zone.today,
|
||||
device: params[:device].presence
|
||||
}
|
||||
|
||||
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
|
||||
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||
|
||||
cell_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to])
|
||||
cell_scope = cell_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||
|
||||
clicks_by_path = cell_scope.group(:page_path).sum(:click_count)
|
||||
moves_by_path = cell_scope.group(:page_path).sum(:move_count)
|
||||
pageviews_by_path = scope.group(:page_path).sum(:pageview_count)
|
||||
scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples)
|
||||
scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct)
|
||||
max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct)
|
||||
|
||||
paths = (pageviews_by_path.keys + clicks_by_path.keys + moves_by_path.keys).uniq
|
||||
@pages = paths.map do |path|
|
||||
samples = scroll_samples_by_path[path].to_i
|
||||
{
|
||||
page_path: path,
|
||||
pageviews: pageviews_by_path[path].to_i,
|
||||
clicks: clicks_by_path[path].to_i,
|
||||
moves: moves_by_path[path].to_i,
|
||||
scroll_samples: samples,
|
||||
scroll_sum: scroll_sum_by_path[path].to_i,
|
||||
max_scroll: max_scroll_by_path[path].to_i
|
||||
}
|
||||
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
|
||||
end
|
||||
|
||||
def show
|
||||
@page_path = params[:page_path].to_s
|
||||
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
|
||||
|
||||
@filters = {
|
||||
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
||||
to: parse_date(params[:to]) || Time.zone.today,
|
||||
device: params[:device].presence,
|
||||
layer: params[:layer].to_s
|
||||
}
|
||||
|
||||
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
|
||||
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||
|
||||
@click_total = cells.sum(:click_count)
|
||||
@move_total = cells.sum(:move_count)
|
||||
@filters[:layer] =
|
||||
if %w[move click].include?(@filters[:layer])
|
||||
@filters[:layer]
|
||||
elsif @move_total.positive?
|
||||
"move"
|
||||
else
|
||||
"click"
|
||||
end
|
||||
|
||||
counter = @filters[:layer] == "click" ? :click_count : :move_count
|
||||
@cells = cells.group(:cell_x, :cell_y).sum(counter)
|
||||
@max_weight = @cells.values.max.to_i
|
||||
@total_points = @cells.values.sum
|
||||
|
||||
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
|
||||
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||
@pageviews = stats.sum(:pageview_count)
|
||||
@scroll_samples = stats.sum(:scroll_samples)
|
||||
@scroll_sum = stats.sum(:scroll_sum_pct)
|
||||
@max_scroll = stats.maximum(:max_scroll_pct).to_i
|
||||
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
|
||||
@grid = AnalyticsPageCell::GRID_SIZE
|
||||
@snapshot = find_snapshot(@page_path, @filters[:device])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_date(value)
|
||||
return nil if value.blank?
|
||||
|
||||
Date.parse(value.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def find_snapshot(page_path, device)
|
||||
scope = AnalyticsPageSnapshot.where(page_path: page_path)
|
||||
if device.present? && AnalyticsEvent::DEVICES.include?(device)
|
||||
snap = scope.find_by(device: device)
|
||||
return snap if snap&.image&.attached?
|
||||
end
|
||||
|
||||
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,8 @@
|
||||
module Admin
|
||||
class AuthController < Admin::BaseController
|
||||
skip_before_action :require_admin_login, only: %i[new create]
|
||||
# Same as public logout: tolerate stale CSRF after long-lived admin tabs.
|
||||
skip_before_action :verify_authenticity_token, only: :destroy
|
||||
|
||||
def new
|
||||
redirect_to admin_root_path if admin_logged_in?
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
module Admin
|
||||
class SessionsController < Admin::BaseController
|
||||
def index
|
||||
@sessions = StreamSession.includes(:stream_node, :user, match: :team).order(created_at: :desc).limit(50)
|
||||
@filters = session_filters
|
||||
scope = filtered_sessions
|
||||
@total_count = scope.count
|
||||
@sessions = scope
|
||||
.includes(:stream_node, :user, match: { team: :club })
|
||||
.order(Arel.sql("COALESCE(stream_sessions.started_at, stream_sessions.created_at) DESC"))
|
||||
.limit(100)
|
||||
@clubs = Club.order(:name)
|
||||
@nodes = StreamNode.order(:slug)
|
||||
end
|
||||
|
||||
def show
|
||||
@session = StreamSession.includes(:stream_node, match: :team).find(params[:id])
|
||||
@events = @session.stream_events.recent.limit(50)
|
||||
@session = StreamSession
|
||||
.includes(:stream_node, :user, :device_states, :recording, match: { team: :club })
|
||||
.find(params[:id])
|
||||
@events = @session.stream_events.recent.limit(100)
|
||||
@club = @session.match.team.club
|
||||
end
|
||||
|
||||
def stop
|
||||
@@ -39,5 +50,73 @@ module Admin
|
||||
regia_expires_at: @session.regia_token_expires_at&.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def session_filters
|
||||
{
|
||||
q: params[:q].to_s.strip.presence,
|
||||
status: params[:status].to_s.strip.presence,
|
||||
platform: params[:platform].to_s.strip.presence,
|
||||
club_id: params[:club_id].to_s.strip.presence,
|
||||
stream_node_id: params[:stream_node_id].to_s.strip.presence,
|
||||
from: params[:from].to_s.strip.presence,
|
||||
to: params[:to].to_s.strip.presence
|
||||
}
|
||||
end
|
||||
|
||||
def filtered_sessions
|
||||
scope = StreamSession.left_outer_joins(match: { team: :club })
|
||||
|
||||
if @filters[:q].present?
|
||||
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
|
||||
scope = scope.where(
|
||||
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
|
||||
term: term
|
||||
)
|
||||
end
|
||||
|
||||
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
|
||||
scope = scope.where(stream_sessions: { status: @filters[:status] })
|
||||
end
|
||||
if @filters[:platform].present? && StreamSession::LIVE_PLATFORMS.include?(@filters[:platform])
|
||||
scope = scope.where(stream_sessions: { platform: @filters[:platform] })
|
||||
end
|
||||
if @filters[:club_id].present?
|
||||
scope = scope.where(teams: { club_id: @filters[:club_id] })
|
||||
end
|
||||
if @filters[:stream_node_id].present?
|
||||
node = StreamNode.find_by(id: @filters[:stream_node_id])
|
||||
if node
|
||||
scope = scope.where(
|
||||
"stream_sessions.stream_node_id = :id OR stream_sessions.ingest_slug = :slug",
|
||||
id: node.id,
|
||||
slug: node.slug
|
||||
)
|
||||
end
|
||||
end
|
||||
if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
|
||||
scope = scope.where(
|
||||
"COALESCE(stream_sessions.started_at, stream_sessions.created_at) >= ?",
|
||||
from_time
|
||||
)
|
||||
end
|
||||
if (to_time = parse_filter_date(@filters[:to], end_of_day: true))
|
||||
scope = scope.where(
|
||||
"COALESCE(stream_sessions.started_at, stream_sessions.created_at) <= ?",
|
||||
to_time
|
||||
)
|
||||
end
|
||||
scope
|
||||
end
|
||||
|
||||
def parse_filter_date(value, end_of_day:)
|
||||
return nil if value.blank?
|
||||
|
||||
date = Date.parse(value)
|
||||
end_of_day ? date.end_of_day : date.beginning_of_day
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Analytics
|
||||
class EventsController < ActionController::API
|
||||
def create
|
||||
payload = parse_payload
|
||||
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
|
||||
|
||||
if result.rate_limited
|
||||
return head :too_many_requests
|
||||
end
|
||||
|
||||
render json: { accepted: result.accepted, rejected: result.rejected }, status: :accepted
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_payload
|
||||
body = request.request_parameters
|
||||
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
|
||||
return body if body.is_a?(Array)
|
||||
|
||||
raw = request.raw_post
|
||||
return [] if raw.blank?
|
||||
|
||||
parsed = JSON.parse(raw)
|
||||
return parsed["events"] if parsed.is_a?(Hash) && parsed["events"].is_a?(Array)
|
||||
return parsed if parsed.is_a?(Array)
|
||||
|
||||
[]
|
||||
rescue JSON::ParserError
|
||||
[]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Analytics
|
||||
class SnapshotsController < ActionController::API
|
||||
def create
|
||||
result = Analytics::SnapshotIngest.new(
|
||||
path: params[:path] || params[:page_path],
|
||||
device: params[:device],
|
||||
width: params[:width],
|
||||
height: params[:height],
|
||||
image: params[:image],
|
||||
remote_ip: request.remote_ip
|
||||
).call
|
||||
|
||||
return head :too_many_requests if result.rate_limited
|
||||
return render json: { ok: false, error: result.error }, status: :unprocessable_content unless result.ok
|
||||
|
||||
render json: { ok: true, skipped: result.skipped }, status: :accepted
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ module Api
|
||||
module V1
|
||||
class BaseController < ApplicationController
|
||||
rescue_from Teams::EntitlementError, with: :render_entitlement_error
|
||||
rescue_from Streams::IngestUnavailableError, with: :render_ingest_unavailable
|
||||
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
|
||||
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
|
||||
|
||||
@@ -22,6 +23,13 @@ module Api
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def render_ingest_unavailable(error)
|
||||
render json: {
|
||||
error: error.message,
|
||||
error_code: error.code
|
||||
}, status: :service_unavailable
|
||||
end
|
||||
|
||||
def render_cover_upload_error(error)
|
||||
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -97,10 +97,11 @@ module Api
|
||||
replay_url: recording.replay_url,
|
||||
playback_url: recording.playback_stream_url,
|
||||
thumbnail_url: recording.thumbnail_url,
|
||||
download_enabled: ent.phone_download_enabled?,
|
||||
download_enabled: ent.phone_download_enabled? && recording.storage_key.present?,
|
||||
youtube_video_id: recording.youtube_video_id,
|
||||
youtube_watch_url: recording.youtube_watch_url,
|
||||
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
|
||||
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled? && recording.storage_key.present? && recording.youtube_video_id.blank?,
|
||||
replay_source: recording.replay_source,
|
||||
source_platform: recording.source_platform,
|
||||
source_platform_label: recording.source_platform_label,
|
||||
expires_at: recording.expires_at,
|
||||
|
||||
@@ -77,6 +77,7 @@ module Api
|
||||
thermal_state: sanitized_thermal_state,
|
||||
last_seen_at: Time.current
|
||||
)
|
||||
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
|
||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||
SessionChannel.broadcast_message(@session, state.as_cable_payload)
|
||||
head :no_content
|
||||
@@ -180,7 +181,12 @@ module Api
|
||||
end
|
||||
|
||||
def session_params
|
||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
||||
params.permit(
|
||||
:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel,
|
||||
client: %i[os client_os app_version app_build version build build_number
|
||||
device_manufacturer manufacturer device_model model
|
||||
os_version system_version carrier network_operator operator]
|
||||
)
|
||||
end
|
||||
|
||||
def score_sync_params
|
||||
|
||||
@@ -171,12 +171,13 @@ module Api
|
||||
replay_url: recording.replay_url,
|
||||
playback_url: recording.playback_stream_url,
|
||||
thumbnail_url: recording.thumbnail_url,
|
||||
download_enabled: ent.phone_download_enabled?,
|
||||
download_enabled: ent.phone_download_enabled? && recording.storage_key.present?,
|
||||
view_count: recording.view_count,
|
||||
views_label: recording.views_label,
|
||||
youtube_video_id: recording.youtube_video_id,
|
||||
youtube_watch_url: recording.youtube_watch_url,
|
||||
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
|
||||
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled? && recording.storage_key.present? && recording.youtube_video_id.blank?,
|
||||
replay_source: recording.replay_source,
|
||||
source_platform: recording.source_platform,
|
||||
source_platform_label: recording.source_platform_label,
|
||||
expires_at: recording.expires_at,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
module Public
|
||||
class SessionsController < WebBaseController
|
||||
# Logout must succeed even with a stale CSRF token (old tab after deploy /
|
||||
# session rotate). SameSite=Lax already blocks cross-site cookie POSTs.
|
||||
skip_before_action :verify_authenticity_token, only: :destroy
|
||||
|
||||
def new
|
||||
if logged_in? && current_user.primary_club
|
||||
redirect_to public_club_path(current_user.primary_club)
|
||||
|
||||
@@ -30,8 +30,9 @@ module AdminHelper
|
||||
links
|
||||
end
|
||||
|
||||
def admin_session_ingest_badge_class(node)
|
||||
case node.role
|
||||
def admin_session_ingest_badge_class(role_or_node)
|
||||
role = role_or_node.respond_to?(:role) ? role_or_node.role : role_or_node
|
||||
case role.to_s
|
||||
when "home" then "badge--ingest-home"
|
||||
when "lab" then "badge--ingest-lab"
|
||||
when "cloud" then "badge--ingest-cloud"
|
||||
@@ -39,8 +40,101 @@ module AdminHelper
|
||||
end
|
||||
end
|
||||
|
||||
def admin_session_ingest_role_label(node)
|
||||
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize)
|
||||
def admin_session_ingest_role_label(role_or_node)
|
||||
role = role_or_node.respond_to?(:role) ? role_or_node.role : role_or_node
|
||||
I18n.t("admin.sessions.ingest.role.#{role}", default: role.to_s.humanize)
|
||||
end
|
||||
|
||||
def admin_session_status_badge_class(status)
|
||||
case status.to_s
|
||||
when "live" then "badge--live"
|
||||
when "connecting", "reconnecting" then "badge--connecting"
|
||||
when "paused", "idle" then "badge--paused"
|
||||
when "ended" then "badge--ended"
|
||||
when "error" then "badge--error"
|
||||
else "badge--paused"
|
||||
end
|
||||
end
|
||||
|
||||
def admin_datetime(time, with_seconds: false)
|
||||
return I18n.t("admin.common.dash") if time.blank?
|
||||
|
||||
format = with_seconds ? "%d/%m/%Y %H:%M:%S" : "%d/%m/%Y %H:%M"
|
||||
time.in_time_zone.strftime(format)
|
||||
end
|
||||
|
||||
def admin_session_duration_label(session)
|
||||
secs = session.total_duration_secs.to_i
|
||||
if secs <= 0 && session.started_at
|
||||
end_time = session.ended_at || (session.terminal? ? session.updated_at : Time.current)
|
||||
secs = (end_time - session.started_at).to_i if end_time
|
||||
end
|
||||
return I18n.t("admin.common.dash") if secs <= 0
|
||||
|
||||
hours = secs / 3600
|
||||
mins = (secs % 3600) / 60
|
||||
rem = secs % 60
|
||||
if hours.positive?
|
||||
format("%dh %02dm %02ds", hours, mins, rem)
|
||||
elsif mins.positive?
|
||||
format("%dm %02ds", mins, rem)
|
||||
else
|
||||
"#{rem}s"
|
||||
end
|
||||
end
|
||||
|
||||
def admin_session_client_os_label(session)
|
||||
case session.client_os.to_s
|
||||
when "android" then "Android"
|
||||
when "ios" then "iOS"
|
||||
else I18n.t("admin.common.dash")
|
||||
end
|
||||
end
|
||||
|
||||
def admin_session_client_summary(session)
|
||||
parts = []
|
||||
parts << admin_session_client_os_label(session) if session.client_os.present?
|
||||
if session.app_version.present?
|
||||
ver = session.app_version
|
||||
ver = "#{ver} (#{session.app_build})" if session.app_build.present?
|
||||
parts << "app #{ver}"
|
||||
end
|
||||
device = [session.device_manufacturer, session.device_model].compact_blank.join(" ")
|
||||
parts << device if device.present?
|
||||
parts << "OS #{session.os_version}" if session.os_version.present?
|
||||
parts << session.carrier if session.carrier.present?
|
||||
parts.presence&.join(" · ") || I18n.t("admin.common.dash")
|
||||
end
|
||||
|
||||
def admin_format_event_meta(metadata)
|
||||
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
|
||||
|
||||
pairs = metadata.to_h
|
||||
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if pairs.empty?
|
||||
|
||||
content_tag(:dl, class: "admin-event-meta") do
|
||||
safe_join(
|
||||
pairs.map do |key, value|
|
||||
content_tag(:div, class: "admin-event-meta__row") do
|
||||
content_tag(:dt, key.to_s) +
|
||||
content_tag(:dd, admin_event_meta_value(value))
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def admin_event_meta_value(value)
|
||||
case value
|
||||
when Hash, Array
|
||||
content_tag(:code, value.to_json)
|
||||
when TrueClass, FalseClass
|
||||
value ? "true" : "false"
|
||||
when NilClass
|
||||
I18n.t("admin.common.dash")
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def admin_regia_expires_label(iso_time)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Analytics
|
||||
class AggregateJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(purge: false)
|
||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
lock_key = "analytics:aggregate:lock"
|
||||
acquired = redis.set(lock_key, "1", nx: true, ex: 120)
|
||||
return unless acquired
|
||||
|
||||
begin
|
||||
service = Analytics::Aggregate.new
|
||||
service.call
|
||||
service.purge_old! if purge
|
||||
ensure
|
||||
redis.del(lock_key)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[analytics] aggregate failed: #{e.class} #{e.message}")
|
||||
raise
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
class ClearTemporaryMediaJob
|
||||
include Sidekiq::Job
|
||||
sidekiq_options retry: 3, queue: "default"
|
||||
|
||||
def perform(recording_id, reason = "verified")
|
||||
recording = Recording.find_by(id: recording_id)
|
||||
return unless recording
|
||||
|
||||
Recordings::ClearTemporaryMedia.new(
|
||||
recording,
|
||||
reason: reason.to_sym,
|
||||
force: reason.to_s == "verified"
|
||||
).call
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
class PostProcessJob
|
||||
include Sidekiq::Job
|
||||
@@ -7,10 +9,30 @@ module Recordings
|
||||
recording = Recording.find_by(id: recording_id)
|
||||
return unless recording&.ready?
|
||||
|
||||
begin
|
||||
Recordings::NotifyReady.new(recording).call
|
||||
rescue StandardError => e
|
||||
# SMTP/ntfy giù non deve bloccare VerifyYoutubeReplay né il resto del post-process.
|
||||
Rails.logger.warn(
|
||||
"[Recordings::PostProcessJob] notify_failed recording=#{recording.id} " \
|
||||
"#{e.class}: #{e.message}"
|
||||
)
|
||||
end
|
||||
|
||||
if recording.temporary_storage?
|
||||
grace = MatchLiveTv.youtube_replay_verify_grace_secs
|
||||
Rails.logger.info(
|
||||
"[Recordings::PostProcessJob] schedule VerifyYoutubeReplay " \
|
||||
"recording=#{recording.id} grace=#{grace}s"
|
||||
)
|
||||
Recordings::VerifyYoutubeReplayJob.perform_in(grace.seconds, recording.id, 0)
|
||||
return
|
||||
end
|
||||
|
||||
# Solo MatchLiveTV-only: eventuale re-upload manuale/flag legacy (non per live YouTube).
|
||||
return unless recording.auto_publish_youtube?
|
||||
return if recording.youtube_video_id.present?
|
||||
return if recording.source_platform == "youtube"
|
||||
|
||||
Recordings::PublishToYoutubeJob.perform_async(recording.id)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
# Safety net: elimina copie temp scadute (anche se verify non è mai riuscito).
|
||||
class PurgeTemporaryMediaJob
|
||||
include Sidekiq::Job
|
||||
sidekiq_options retry: 1, queue: "default"
|
||||
|
||||
def perform
|
||||
scope = Recording.temporary_media_pending_purge
|
||||
count = 0
|
||||
|
||||
scope.find_each do |recording|
|
||||
unless recording.youtube_verified_at.present?
|
||||
Rails.logger.warn(
|
||||
"[Recordings::PurgeTemporaryMediaJob] anomaly_unverified_expiry " \
|
||||
"recording=#{recording.id} session=#{recording.stream_session_id} " \
|
||||
"youtube_video_id=#{recording.youtube_video_id.inspect} " \
|
||||
"temp_expires_at=#{recording.temp_expires_at.inspect}"
|
||||
)
|
||||
end
|
||||
|
||||
Recordings::ClearTemporaryMedia.new(
|
||||
recording,
|
||||
reason: :max_retention,
|
||||
force: true
|
||||
).call
|
||||
count += 1
|
||||
end
|
||||
|
||||
Rails.logger.info("[Recordings::PurgeTemporaryMediaJob] processed=#{count}")
|
||||
count
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
class VerifyYoutubeReplayJob
|
||||
include Sidekiq::Job
|
||||
sidekiq_options retry: 0, queue: "default"
|
||||
|
||||
def perform(recording_id, attempt = 0)
|
||||
recording = Recording.find_by(id: recording_id)
|
||||
return unless recording&.temporary_storage?
|
||||
return if recording.deleted?
|
||||
|
||||
attempt = attempt.to_i
|
||||
max = MatchLiveTv.youtube_replay_verify_max_attempts
|
||||
result = Recordings::VerifyYoutubeReplay.new(recording).call
|
||||
|
||||
if result.ok?
|
||||
Recordings::ClearTemporaryMediaJob.perform_async(recording.id, "verified")
|
||||
return
|
||||
end
|
||||
|
||||
if result.retriable? && attempt + 1 < max
|
||||
delay = backoff_secs(attempt)
|
||||
Rails.logger.info(
|
||||
"[Recordings::VerifyYoutubeReplayJob] retry recording=#{recording.id} " \
|
||||
"attempt=#{attempt + 1}/#{max} in=#{delay}s msg=#{result.message}"
|
||||
)
|
||||
self.class.perform_in(delay.seconds, recording.id, attempt + 1)
|
||||
return
|
||||
end
|
||||
|
||||
Rails.logger.warn(
|
||||
"[Recordings::VerifyYoutubeReplayJob] give_up recording=#{recording.id} " \
|
||||
"attempts=#{attempt + 1} msg=#{result.message} " \
|
||||
"temp_expires_at=#{recording.temp_expires_at.inspect}"
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def backoff_secs(attempt)
|
||||
base = MatchLiveTv.youtube_replay_verify_base_interval_secs
|
||||
# 300, 600, 1200, ... capped at 1h
|
||||
[base * (2**attempt), 3600].min
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,8 @@ module Streams
|
||||
|
||||
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
|
||||
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
|
||||
KICK_DEBOUNCE_KEY = "streams:autoscaler:kick"
|
||||
KICK_DEBOUNCE_SECS = ENV.fetch("STREAM_AUTOSCALE_KICK_DEBOUNCE_SECS", "5").to_i
|
||||
|
||||
def self.ensure_chain
|
||||
return unless redis
|
||||
@@ -17,6 +19,16 @@ module Streams
|
||||
perform_in(INTERVAL_SECS)
|
||||
end
|
||||
|
||||
# Kick immediato (es. NoCapacity / soft-free bassi). Debounce anti-flood Sidekiq.
|
||||
def self.kick!
|
||||
return false unless Streams::Autoscaler.enabled?
|
||||
return false unless redis
|
||||
return false unless redis.set(KICK_DEBOUNCE_KEY, "1", nx: true, ex: KICK_DEBOUNCE_SECS)
|
||||
|
||||
perform_async
|
||||
true
|
||||
end
|
||||
|
||||
def self.redis
|
||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
rescue Redis::CannotConnectError
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AnalyticsEvent < ApplicationRecord
|
||||
self.table_name = "analytics_events"
|
||||
|
||||
EVENT_TYPES = %w[click scroll pageview move].freeze
|
||||
DEVICES = %w[mobile tablet desktop].freeze
|
||||
|
||||
validates :page_path, presence: true
|
||||
validates :device, inclusion: { in: DEVICES }
|
||||
validates :event_type, inclusion: { in: EVENT_TYPES }
|
||||
validates :occurred_at, presence: true
|
||||
validates :weight, numericality: { greater_than: 0, less_than_or_equal_to: 500 }
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AnalyticsPageCell < ApplicationRecord
|
||||
self.table_name = "analytics_page_cells"
|
||||
|
||||
GRID_SIZE = 40
|
||||
|
||||
validates :day, :page_path, :device, :cell_x, :cell_y, presence: true
|
||||
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
|
||||
validates :cell_x, :cell_y, inclusion: { in: 0...(GRID_SIZE) }
|
||||
validates :click_count, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :move_count, numericality: { greater_than_or_equal_to: 0 }
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AnalyticsPageSnapshot < ApplicationRecord
|
||||
self.table_name = "analytics_page_snapshots"
|
||||
|
||||
has_one_attached :image
|
||||
|
||||
validates :page_path, presence: true
|
||||
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
|
||||
validates :width, :height, numericality: { greater_than: 0 }
|
||||
validates :captured_at, presence: true
|
||||
end
|
||||
@@ -0,0 +1,14 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AnalyticsPageStat < ApplicationRecord
|
||||
self.table_name = "analytics_page_stats"
|
||||
|
||||
validates :day, :page_path, :device, presence: true
|
||||
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
|
||||
|
||||
def avg_scroll_pct
|
||||
return 0 if scroll_samples.to_i <= 0
|
||||
|
||||
(scroll_sum_pct.to_f / scroll_samples).round
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,8 @@ class Recording < ApplicationRecord
|
||||
STATUSES = %w[processing ready expired failed].freeze
|
||||
PRIVACY_STATUSES = %w[public unlisted].freeze
|
||||
STORAGE_BACKENDS = %w[local s3].freeze
|
||||
STORAGE_POLICIES = %w[temporary retained none].freeze
|
||||
REPLAY_SOURCES = %w[youtube matchlivetv none].freeze
|
||||
|
||||
belongs_to :stream_session
|
||||
belongs_to :team
|
||||
@@ -9,6 +11,7 @@ class Recording < ApplicationRecord
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
|
||||
validates :storage_policy, inclusion: { in: STORAGE_POLICIES }
|
||||
|
||||
scope :not_deleted, -> { where(deleted_at: nil) }
|
||||
scope :ready, lambda {
|
||||
@@ -23,7 +26,16 @@ class Recording < ApplicationRecord
|
||||
ready.where(expires_at: ..days.days.from_now)
|
||||
}
|
||||
scope :expired_pending_purge, lambda {
|
||||
not_deleted.where(status: %w[ready failed]).where("expires_at IS NOT NULL AND expires_at <= ?", Time.current)
|
||||
not_deleted
|
||||
.where(storage_policy: "retained")
|
||||
.where(status: %w[ready failed])
|
||||
.where("expires_at IS NOT NULL AND expires_at <= ?", Time.current)
|
||||
}
|
||||
scope :temporary_media_pending_purge, lambda {
|
||||
not_deleted
|
||||
.where(storage_policy: "temporary")
|
||||
.where(local_media_purged_at: nil)
|
||||
.where("temp_expires_at IS NOT NULL AND temp_expires_at <= ?", Time.current)
|
||||
}
|
||||
scope :search_replays, lambda { |query|
|
||||
q = query.to_s.strip
|
||||
@@ -46,19 +58,30 @@ class Recording < ApplicationRecord
|
||||
end
|
||||
|
||||
def playback_stream_url
|
||||
return nil unless ready? && stream_session_id.present?
|
||||
return nil unless ready? && storage_key.present? && stream_session_id.present?
|
||||
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream"
|
||||
end
|
||||
|
||||
def thumbnail_url
|
||||
return youtube_thumbnail_url if thumbnail_storage_key.blank? && youtube_video_id.present?
|
||||
return nil unless thumbnail_storage_key.present? && stream_session_id.present?
|
||||
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
|
||||
end
|
||||
|
||||
def youtube_thumbnail_url
|
||||
return nil if youtube_video_id.blank?
|
||||
return nil if youtube_video_id.to_s.start_with?("mock_")
|
||||
|
||||
meta_url = metadata.is_a?(Hash) ? metadata.dig("youtube", "thumbnail_url") : nil
|
||||
return meta_url if meta_url.present?
|
||||
|
||||
"https://i.ytimg.com/vi/#{youtube_video_id}/hqdefault.jpg"
|
||||
end
|
||||
|
||||
def download_api_path
|
||||
return nil unless ready?
|
||||
return nil unless ready? && storage_key.present?
|
||||
|
||||
"/api/v1/recordings/#{id}/download"
|
||||
end
|
||||
@@ -70,6 +93,30 @@ class Recording < ApplicationRecord
|
||||
"https://www.youtube.com/watch?v=#{youtube_video_id}"
|
||||
end
|
||||
|
||||
def temporary_storage?
|
||||
storage_policy == "temporary"
|
||||
end
|
||||
|
||||
def retained_storage?
|
||||
storage_policy == "retained"
|
||||
end
|
||||
|
||||
def local_media_purged?
|
||||
local_media_purged_at.present?
|
||||
end
|
||||
|
||||
# Sorgente fisica del player in archivio (non lo storage policy).
|
||||
def replay_source
|
||||
return "youtube" if youtube_watch_url.present? || (ready? && youtube_video_id.present?)
|
||||
return "matchlivetv" if ready? && storage_key.present?
|
||||
|
||||
"none"
|
||||
end
|
||||
|
||||
def available_in_archive?
|
||||
ready? && (youtube_watch_url.present? || youtube_video_id.present? || storage_key.present?)
|
||||
end
|
||||
|
||||
def ready?
|
||||
status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
|
||||
end
|
||||
@@ -154,7 +201,7 @@ class Recording < ApplicationRecord
|
||||
end
|
||||
|
||||
def playable_on_site?
|
||||
ready? && storage_key.present?
|
||||
available_in_archive?
|
||||
end
|
||||
|
||||
def days_until_expiry
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
class StreamSession < ApplicationRecord
|
||||
include AASM
|
||||
|
||||
PLATFORMS = %w[matchlivetv youtube facebook twitch].freeze
|
||||
LIVE_PLATFORMS = %w[matchlivetv youtube].freeze
|
||||
PLATFORMS = (LIVE_PLATFORMS + %w[facebook twitch]).freeze
|
||||
STATUSES = %w[idle connecting live reconnecting paused ended error].freeze
|
||||
PRIVACY_STATUSES = %w[public unlisted private].freeze
|
||||
|
||||
@@ -10,6 +11,7 @@ class StreamSession < ApplicationRecord
|
||||
belongs_to :stream_node, optional: true
|
||||
has_many :stream_events, dependent: :destroy
|
||||
has_one :score_state, dependent: :destroy
|
||||
has_one :recording
|
||||
has_many :device_states, dependent: :destroy
|
||||
|
||||
validates :platform, inclusion: { in: PLATFORMS }
|
||||
@@ -19,6 +21,7 @@ class StreamSession < ApplicationRecord
|
||||
|
||||
before_validation :normalize_privacy_status
|
||||
before_validation :ensure_publish_token, on: :create
|
||||
before_validation :snapshot_ingest_from_node, if: -> { stream_node.present? }
|
||||
|
||||
scope :broadcasting, -> { where(status: %w[live connecting reconnecting paused]) }
|
||||
scope :publicly_listed, -> { where(privacy_status: "public") }
|
||||
@@ -73,6 +76,14 @@ class StreamSession < ApplicationRecord
|
||||
end
|
||||
end
|
||||
|
||||
def ingest_slug_display
|
||||
stream_node&.slug.presence || ingest_slug
|
||||
end
|
||||
|
||||
def ingest_role_display
|
||||
stream_node&.role.presence || ingest_role
|
||||
end
|
||||
|
||||
def rtmp_ingest_url
|
||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||
@@ -207,6 +218,11 @@ class StreamSession < ApplicationRecord
|
||||
self.privacy_status = "unlisted" if privacy_status == "private"
|
||||
end
|
||||
|
||||
def snapshot_ingest_from_node
|
||||
self.ingest_slug = stream_node.slug
|
||||
self.ingest_role = stream_node.role
|
||||
end
|
||||
|
||||
def record_ended_timestamps!
|
||||
now = Time.current
|
||||
update!(ended_at: now) if ended_at.nil?
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Analytics
|
||||
class Aggregate
|
||||
BATCH = 500
|
||||
UPSERT_RETRIES = 3
|
||||
|
||||
def call
|
||||
loop do
|
||||
ids = AnalyticsEvent.order(:occurred_at).limit(BATCH).pluck(:id)
|
||||
break if ids.empty?
|
||||
|
||||
events = AnalyticsEvent.where(id: ids).to_a
|
||||
apply_points(events.select { |e| e.event_type == "click" }, :click_count)
|
||||
apply_points(events.select { |e| e.event_type == "move" }, :move_count)
|
||||
apply_stats(events.select { |e| %w[pageview scroll].include?(e.event_type) })
|
||||
AnalyticsEvent.where(id: ids).delete_all
|
||||
end
|
||||
end
|
||||
|
||||
def purge_old!(retention: 30.days)
|
||||
AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all
|
||||
AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all
|
||||
AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all
|
||||
AnalyticsPageSnapshot.where("captured_at < ?", retention.ago).find_each do |snap|
|
||||
snap.image.purge if snap.image.attached?
|
||||
snap.destroy!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_points(events, counter_attr)
|
||||
return if events.empty?
|
||||
|
||||
grid = AnalyticsPageCell::GRID_SIZE
|
||||
grouped = Hash.new(0)
|
||||
events.each do |e|
|
||||
next if e.x_pct.nil? || e.y_pct.nil?
|
||||
|
||||
cell_x = [[(e.x_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max
|
||||
cell_y = [[(e.y_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max
|
||||
key = [e.occurred_at.in_time_zone.to_date, e.page_path, e.device, cell_x, cell_y]
|
||||
grouped[key] += e.weight.to_i.clamp(1, 500)
|
||||
end
|
||||
|
||||
now = Time.current
|
||||
grouped.each do |(day, page_path, device, cell_x, cell_y), count|
|
||||
with_unique_retry do
|
||||
cell = AnalyticsPageCell.find_or_initialize_by(
|
||||
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
|
||||
)
|
||||
cell[counter_attr] = cell[counter_attr].to_i + count
|
||||
cell.created_at ||= now
|
||||
cell.updated_at = now
|
||||
cell.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def apply_stats(events)
|
||||
return if events.empty?
|
||||
|
||||
grouped = {}
|
||||
events.each do |e|
|
||||
day = e.occurred_at.in_time_zone.to_date
|
||||
key = [day, e.page_path, e.device]
|
||||
bucket = grouped[key] ||= { pageviews: 0, scroll_samples: 0, scroll_sum: 0, max_scroll: 0 }
|
||||
case e.event_type
|
||||
when "pageview"
|
||||
bucket[:pageviews] += e.weight.to_i.clamp(1, 500)
|
||||
when "scroll"
|
||||
pct = e.scroll_pct.to_f.round
|
||||
bucket[:scroll_samples] += 1
|
||||
bucket[:scroll_sum] += pct
|
||||
bucket[:max_scroll] = [bucket[:max_scroll], pct].max
|
||||
end
|
||||
end
|
||||
|
||||
now = Time.current
|
||||
grouped.each do |(day, page_path, device), vals|
|
||||
with_unique_retry do
|
||||
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
|
||||
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
|
||||
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
|
||||
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
|
||||
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
|
||||
stat.created_at ||= now
|
||||
stat.updated_at = now
|
||||
stat.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def with_unique_retry
|
||||
attempts = 0
|
||||
begin
|
||||
attempts += 1
|
||||
yield
|
||||
rescue ActiveRecord::RecordNotUnique
|
||||
raise if attempts >= UPSERT_RETRIES
|
||||
|
||||
retry
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,134 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Analytics
|
||||
class Ingest
|
||||
MAX_BATCH = 80
|
||||
RATE_LIMIT_PER_MINUTE = 90
|
||||
|
||||
Result = Struct.new(:accepted, :rejected, :rate_limited, keyword_init: true)
|
||||
|
||||
def initialize(events:, remote_ip:)
|
||||
@events = Array(events)
|
||||
@remote_ip = remote_ip.to_s.presence || "unknown"
|
||||
end
|
||||
|
||||
def call
|
||||
return Result.new(accepted: 0, rejected: 0, rate_limited: true) if rate_limited?
|
||||
|
||||
slice = @events.first(MAX_BATCH)
|
||||
accepted = 0
|
||||
rejected = 0
|
||||
rows = []
|
||||
|
||||
slice.each do |raw|
|
||||
attrs = build_attrs(raw)
|
||||
if attrs
|
||||
rows << attrs
|
||||
accepted += 1
|
||||
else
|
||||
rejected += 1
|
||||
end
|
||||
end
|
||||
|
||||
AnalyticsEvent.insert_all(rows) if rows.any?
|
||||
if rows.any?
|
||||
begin
|
||||
Analytics::Aggregate.new.call
|
||||
rescue ActiveRecord::RecordNotUnique => e
|
||||
# Dopo i retry interni: non far fallire la richiesta analytics.
|
||||
Rails.logger.warn("[analytics] aggregate race after retries, enqueue job: #{e.message}")
|
||||
Analytics::AggregateJob.perform_later
|
||||
end
|
||||
end
|
||||
|
||||
Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rate_limited?
|
||||
key = "analytics:ingest:#{@remote_ip}"
|
||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
count = redis.incr(key)
|
||||
redis.expire(key, 60) if count == 1
|
||||
count > RATE_LIMIT_PER_MINUTE
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[analytics] rate limit unavailable: #{e.class}")
|
||||
false
|
||||
end
|
||||
|
||||
def build_attrs(raw)
|
||||
data = raw.is_a?(Hash) ? raw.with_indifferent_access : {}
|
||||
event_type = data[:type].to_s.presence || data[:event_type].to_s
|
||||
return nil unless AnalyticsEvent::EVENT_TYPES.include?(event_type)
|
||||
|
||||
device = data[:device].to_s
|
||||
return nil unless AnalyticsEvent::DEVICES.include?(device)
|
||||
|
||||
page_path = PathNormalizer.normalize(data[:path] || data[:page_path])
|
||||
return nil if PathNormalizer.excluded?(page_path)
|
||||
return nil if page_path.length > 200
|
||||
|
||||
occurred_at = parse_time(data[:ts] || data[:occurred_at]) || Time.current
|
||||
weight = data[:n].presence || data[:weight].presence || 1
|
||||
weight = Integer(weight)
|
||||
weight = 1 if weight < 1
|
||||
weight = 500 if weight > 500
|
||||
|
||||
attrs = {
|
||||
page_path: page_path,
|
||||
device: device,
|
||||
event_type: event_type,
|
||||
occurred_at: occurred_at,
|
||||
weight: weight,
|
||||
x_pct: nil,
|
||||
y_pct: nil,
|
||||
scroll_pct: nil,
|
||||
created_at: Time.current,
|
||||
updated_at: Time.current
|
||||
}
|
||||
|
||||
case event_type
|
||||
when "click", "move"
|
||||
x = clamp_pct(data[:x] || data[:x_pct])
|
||||
y = clamp_pct(data[:y] || data[:y_pct])
|
||||
return nil if x.nil? || y.nil?
|
||||
|
||||
attrs[:x_pct] = x
|
||||
attrs[:y_pct] = y
|
||||
when "scroll"
|
||||
scroll = clamp_pct(data[:scroll] || data[:scroll_pct])
|
||||
return nil if scroll.nil?
|
||||
|
||||
attrs[:scroll_pct] = scroll
|
||||
when "pageview"
|
||||
# no extra fields
|
||||
end
|
||||
|
||||
attrs
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def clamp_pct(value)
|
||||
return nil if value.nil?
|
||||
|
||||
n = Float(value)
|
||||
return nil if n.nan? || n.infinite?
|
||||
return nil if n.negative? || n > 100
|
||||
|
||||
n.round(2)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def parse_time(value)
|
||||
return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
|
||||
return Time.zone.at(value.to_i / 1000.0) if value.is_a?(Numeric) || value.to_s.match?(/\A\d{10,13}\z/)
|
||||
|
||||
Time.zone.parse(value.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Analytics
|
||||
class PathNormalizer
|
||||
UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
||||
NUMERIC_RE = /\A\d{6,}\z/
|
||||
TOKENISH_RE = /\A[A-Za-z0-9_\-]{22,}\z/
|
||||
|
||||
EXCLUDED_PREFIXES = %w[/admin /regia /analytics /cable /api /webhooks /internal].freeze
|
||||
|
||||
class << self
|
||||
def normalize(raw_path)
|
||||
path = raw_path.to_s.split("?", 2).first.to_s
|
||||
path = "/" if path.blank?
|
||||
path = "/#{path}" unless path.start_with?("/")
|
||||
path = path.gsub(%r{/{2,}}, "/").chomp("/")
|
||||
path = "/" if path.blank?
|
||||
|
||||
parts = path.split("/").map do |segment|
|
||||
next segment if segment.blank?
|
||||
next ":id" if UUID_RE.match?(segment)
|
||||
next ":id" if NUMERIC_RE.match?(segment)
|
||||
next ":token" if TOKENISH_RE.match?(segment) && segment.match?(/[A-Z]/)
|
||||
|
||||
segment
|
||||
end
|
||||
normalized = parts.join("/")
|
||||
normalized = "/" if normalized.blank?
|
||||
normalized
|
||||
end
|
||||
|
||||
def excluded?(raw_or_normalized)
|
||||
path = normalize(raw_or_normalized)
|
||||
return true if EXCLUDED_PREFIXES.any? { |p| path == p || path.start_with?("#{p}/") }
|
||||
return true if path.match?(%r{\A/live/[^/]+})
|
||||
return true if path.match?(%r{\A/replay/[^/]+})
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,84 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Analytics
|
||||
class SnapshotIngest
|
||||
MAX_BYTES = 1_800_000
|
||||
MIN_REFRESH = 12.hours
|
||||
RATE_LIMIT_PER_HOUR = 20
|
||||
|
||||
Result = Struct.new(:ok, :skipped, :rate_limited, :error, keyword_init: true)
|
||||
|
||||
def initialize(path:, device:, width:, height:, image:, remote_ip:)
|
||||
@path = path
|
||||
@device = device.to_s
|
||||
@width = width
|
||||
@height = height
|
||||
@image = image
|
||||
@remote_ip = remote_ip.to_s.presence || "unknown"
|
||||
end
|
||||
|
||||
def call
|
||||
return Result.new(ok: false, skipped: false, rate_limited: true) if rate_limited?
|
||||
|
||||
page_path = PathNormalizer.normalize(@path)
|
||||
return Result.new(ok: false, skipped: true, error: "path") if PathNormalizer.excluded?(page_path)
|
||||
return Result.new(ok: false, skipped: true, error: "device") unless AnalyticsEvent::DEVICES.include?(@device)
|
||||
return Result.new(ok: false, skipped: true, error: "image") unless valid_image?
|
||||
|
||||
width = Integer(@width)
|
||||
height = Integer(@height)
|
||||
return Result.new(ok: false, skipped: true, error: "size") if width < 200 || height < 200 || width > 6000 || height > 20000
|
||||
|
||||
snap = AnalyticsPageSnapshot.find_or_initialize_by(page_path: page_path, device: @device)
|
||||
if snap.persisted? && snap.captured_at.present? && snap.captured_at > MIN_REFRESH.ago && snap.image.attached?
|
||||
return Result.new(ok: true, skipped: true)
|
||||
end
|
||||
|
||||
snap.width = width
|
||||
snap.height = height
|
||||
snap.captured_at = Time.current
|
||||
snap.save!
|
||||
snap.image.purge if snap.image.attached?
|
||||
snap.image.attach(
|
||||
io: image_io,
|
||||
filename: "analytics-#{Digest::SHA1.hexdigest(page_path)[0, 12]}-#{@device}.jpg",
|
||||
content_type: "image/jpeg"
|
||||
)
|
||||
|
||||
Result.new(ok: true, skipped: false)
|
||||
rescue ArgumentError, TypeError, ActiveRecord::RecordInvalid => e
|
||||
Result.new(ok: false, skipped: true, error: e.class.name)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rate_limited?
|
||||
key = "analytics:snapshot:#{@remote_ip}"
|
||||
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||
count = redis.incr(key)
|
||||
redis.expire(key, 3600) if count == 1
|
||||
count > RATE_LIMIT_PER_HOUR
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[analytics] snapshot rate limit unavailable: #{e.class}")
|
||||
false
|
||||
end
|
||||
|
||||
def valid_image?
|
||||
return false if @image.blank?
|
||||
return false unless @image.respond_to?(:tempfile) || @image.respond_to?(:read)
|
||||
return false if @image.respond_to?(:size) && @image.size.to_i > MAX_BYTES
|
||||
|
||||
content_type = @image.content_type.to_s if @image.respond_to?(:content_type)
|
||||
content_type.blank? || content_type.start_with?("image/")
|
||||
end
|
||||
|
||||
def image_io
|
||||
if @image.respond_to?(:tempfile)
|
||||
@image.tempfile.rewind
|
||||
@image.tempfile
|
||||
else
|
||||
StringIO.new(@image.read)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,9 @@ module Mediamtx
|
||||
class Client
|
||||
class Error < StandardError; end
|
||||
|
||||
CREATE_PATH_RETRIES = -> { ENV.fetch("MEDIAMTX_CREATE_RETRIES", "5").to_i }
|
||||
CREATE_PATH_RETRY_BASE_SECS = -> { ENV.fetch("MEDIAMTX_CREATE_RETRY_BASE_SECS", "0.4").to_f }
|
||||
|
||||
def self.for_session(session)
|
||||
new(base_url: session.mediamtx_api_base_url)
|
||||
end
|
||||
@@ -19,6 +22,19 @@ module Mediamtx
|
||||
|
||||
attr_reader :base_url
|
||||
|
||||
# Health probe for CPX readiness (GET /v3/paths/list).
|
||||
def reachable?(timeout: 2)
|
||||
conn = Faraday.new(url: @base_url) do |f|
|
||||
f.adapter Faraday.default_adapter
|
||||
f.options.open_timeout = timeout
|
||||
f.options.timeout = timeout
|
||||
end
|
||||
response = conn.get("/v3/paths/list")
|
||||
response.success?
|
||||
rescue Faraday::Error
|
||||
false
|
||||
end
|
||||
|
||||
def create_path(session)
|
||||
path = session.mediamtx_path_name
|
||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||
@@ -30,7 +46,9 @@ module Mediamtx
|
||||
body[:alwaysAvailable] = true
|
||||
body[:alwaysAvailableFile] = slate_file_path(session)
|
||||
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
|
||||
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||
response = with_connection_retries("create_path #{path}") do
|
||||
@conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||
end
|
||||
unless response.success?
|
||||
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||
raise Error, "MediaMTX path create failed: #{response.status} #{err}"
|
||||
@@ -161,6 +179,23 @@ module Mediamtx
|
||||
|
||||
private
|
||||
|
||||
def with_connection_retries(label)
|
||||
attempts = [CREATE_PATH_RETRIES.call, 1].max
|
||||
base = CREATE_PATH_RETRY_BASE_SECS.call
|
||||
try = 0
|
||||
begin
|
||||
try += 1
|
||||
yield
|
||||
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
||||
raise if try >= attempts
|
||||
|
||||
sleep_secs = base * (2**(try - 1))
|
||||
Rails.logger.warn("[Mediamtx::Client] #{label} retry #{try}/#{attempts} after #{e.class}: #{e.message} (sleep #{sleep_secs}s)")
|
||||
sleep(sleep_secs)
|
||||
retry
|
||||
end
|
||||
end
|
||||
|
||||
def recording_body(session, enabled:)
|
||||
ent = session.match.team.entitlements
|
||||
can_record = ent.recording_enabled_for_mediamtx?
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
# Rimuove solo l'MP4 temporaneo su object storage. Non soft-delete, non tocca YouTube.
|
||||
class ClearTemporaryMedia
|
||||
def initialize(recording, reason: :verified, force: false)
|
||||
@recording = recording
|
||||
@reason = reason
|
||||
@force = force
|
||||
end
|
||||
|
||||
def call
|
||||
unless @recording.temporary_storage?
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] skip recording=#{@recording.id} not_temporary"
|
||||
)
|
||||
return @recording
|
||||
end
|
||||
|
||||
if @recording.local_media_purged?
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] already_purged recording=#{@recording.id}"
|
||||
)
|
||||
return @recording
|
||||
end
|
||||
|
||||
unless allowed?
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] skip recording=#{@recording.id} " \
|
||||
"reason=not_verified_and_not_expired force=#{@force}"
|
||||
)
|
||||
return @recording
|
||||
end
|
||||
|
||||
delete_video_object!
|
||||
@recording.update!(
|
||||
storage_key: nil,
|
||||
byte_size: nil,
|
||||
local_media_purged_at: Time.current
|
||||
)
|
||||
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] purged recording=#{@recording.id} " \
|
||||
"reason=#{@reason} youtube_verified=#{@recording.youtube_verified_at.present?} " \
|
||||
"youtube_video_id=#{@recording.youtube_video_id.inspect}"
|
||||
)
|
||||
@recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def allowed?
|
||||
return true if @force
|
||||
return true if @recording.youtube_verified_at.present?
|
||||
return true if @recording.temp_expires_at.present? && @recording.temp_expires_at <= Time.current
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def delete_video_object!
|
||||
key = @recording.storage_key
|
||||
return if key.blank?
|
||||
|
||||
Recordings::Storage.new.delete(key: key)
|
||||
rescue Recordings::Storage::Error => e
|
||||
# File già assente → ok (idempotente)
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] storage delete recording=#{@recording.id}: #{e.message}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
class FinalizeSession
|
||||
def initialize(session)
|
||||
@@ -5,29 +7,69 @@ module Recordings
|
||||
end
|
||||
|
||||
def call
|
||||
team = @session.match.team
|
||||
return unless team.entitlements.can_create_recordings?
|
||||
policy = Recordings::StoragePolicy.call(@session)
|
||||
return if policy == Recordings::StoragePolicy::NONE
|
||||
|
||||
retention_days = team.entitlements.recording_retention_days
|
||||
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
|
||||
team = @session.match.team
|
||||
attrs = attributes_for(policy, team)
|
||||
|
||||
recording = Recording.find_or_initialize_by(stream_session: @session)
|
||||
recording.assign_attributes(
|
||||
if skip_reinitialize?(recording)
|
||||
Rails.logger.info(
|
||||
"[Recordings::FinalizeSession] skip already-finalized " \
|
||||
"recording=#{recording.id} status=#{recording.status}"
|
||||
)
|
||||
return recording
|
||||
end
|
||||
recording.assign_attributes(attrs)
|
||||
recording.save!
|
||||
|
||||
Rails.logger.info(
|
||||
"[Recordings::FinalizeSession] session=#{@session.id} recording=#{recording.id} " \
|
||||
"storage_policy=#{policy} expires_at=#{recording.expires_at.inspect} " \
|
||||
"temp_expires_at=#{recording.temp_expires_at.inspect}"
|
||||
)
|
||||
recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_reinitialize?(recording)
|
||||
return false unless recording.persisted?
|
||||
|
||||
recording.ready? ||
|
||||
recording.status == "processing" ||
|
||||
recording.storage_key.present?
|
||||
end
|
||||
|
||||
def attributes_for(policy, team)
|
||||
{
|
||||
team: team,
|
||||
status: "processing",
|
||||
title: default_title,
|
||||
privacy_status: privacy_from_session,
|
||||
storage_path: @session.mediamtx_path_name,
|
||||
recorded_at: @session.ended_at || Time.current,
|
||||
expires_at: expires_at,
|
||||
storage_policy: policy,
|
||||
expires_at: archive_expires_at(policy, team),
|
||||
temp_expires_at: temp_expires_at_for(policy),
|
||||
error_message: nil,
|
||||
metadata: initial_metadata(team)
|
||||
)
|
||||
recording.save!
|
||||
recording
|
||||
metadata: initial_metadata(policy, team)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def archive_expires_at(policy, team)
|
||||
return nil if policy == Recordings::StoragePolicy::TEMPORARY
|
||||
|
||||
retention_days = team.entitlements.recording_retention_days
|
||||
retention_days.positive? ? retention_days.days.from_now : nil
|
||||
end
|
||||
|
||||
def temp_expires_at_for(policy)
|
||||
return nil unless policy == Recordings::StoragePolicy::TEMPORARY
|
||||
|
||||
MatchLiveTv.youtube_temp_replay_retention_hours.hours.from_now
|
||||
end
|
||||
|
||||
def default_title
|
||||
match = @session.match
|
||||
@@ -38,12 +80,13 @@ module Recordings
|
||||
@session.privacy_status == "public" ? "public" : "unlisted"
|
||||
end
|
||||
|
||||
def initial_metadata(team)
|
||||
ent = team.entitlements
|
||||
def initial_metadata(policy, team)
|
||||
{
|
||||
"source_platform" => @session.platform,
|
||||
"session_privacy" => @session.privacy_status,
|
||||
"auto_publish_youtube" => ent.premium_full? && ent.youtube_enabled? && @session.platform == "youtube",
|
||||
# Re-upload automatico disabilitato: le live YouTube usano VerifyYoutubeReplay.
|
||||
"auto_publish_youtube" => false,
|
||||
"storage_policy" => policy,
|
||||
"ai" => {}
|
||||
}
|
||||
end
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "cgi"
|
||||
require "fileutils"
|
||||
require "net/http"
|
||||
require "uri"
|
||||
|
||||
module Recordings
|
||||
# Recupera i segmenti MediaMTX dal disco del CPX (agent :9100) verso un tmpdir locale.
|
||||
class PullFromCloudNode
|
||||
class Error < StandardError; end
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def applicable?
|
||||
node = @session.stream_node
|
||||
node.present? && node.role == "cloud" && agent_url.present?
|
||||
end
|
||||
|
||||
# @return [String, nil] directory con i file, o nil se il nodo non è cloud / 404
|
||||
def fetch
|
||||
return unless applicable?
|
||||
|
||||
dest = Dir.mktmpdir("mltv-cpx-rec-")
|
||||
uri = recordings_uri
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.open_timeout = 5
|
||||
http.read_timeout = ENV.fetch("STREAM_NODE_RECORDINGS_PULL_TIMEOUT", "180").to_i
|
||||
req = Net::HTTP::Get.new(uri)
|
||||
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
|
||||
res = http.request(req)
|
||||
if res.is_a?(Net::HTTPNotFound)
|
||||
FileUtils.remove_entry(dest)
|
||||
return nil
|
||||
end
|
||||
unless res.is_a?(Net::HTTPSuccess) && res.body.present?
|
||||
FileUtils.remove_entry(dest)
|
||||
raise Error, "agent GET recordings HTTP #{res.code} #{res.body.to_s.truncate(200)}"
|
||||
end
|
||||
|
||||
tar_path = File.join(dest, "recordings.tar.gz")
|
||||
File.binwrite(tar_path, res.body)
|
||||
unpack!(tar_path, dest)
|
||||
FileUtils.rm_f(tar_path)
|
||||
dest
|
||||
rescue StandardError
|
||||
FileUtils.remove_entry(dest) if dest && Dir.exist?(dest)
|
||||
raise
|
||||
end
|
||||
|
||||
def cleanup_remote!
|
||||
return unless applicable?
|
||||
|
||||
uri = recordings_uri
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.open_timeout = 5
|
||||
http.read_timeout = 15
|
||||
req = Net::HTTP::Delete.new(uri)
|
||||
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
|
||||
res = http.request(req)
|
||||
return if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPNotFound)
|
||||
|
||||
Rails.logger.warn(
|
||||
"[Recordings::PullFromCloudNode] delete HTTP #{res.code} session=#{@session.id}"
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[Recordings::PullFromCloudNode] delete #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recordings_uri
|
||||
path = @session.mediamtx_path_name.to_s
|
||||
URI.parse("#{agent_url.chomp('/')}/recordings/#{CGI.escape(path)}")
|
||||
end
|
||||
|
||||
def agent_url
|
||||
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || @session.stream_node&.relay_agent_url
|
||||
end
|
||||
|
||||
def agent_secret
|
||||
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
|
||||
end
|
||||
|
||||
def unpack!(tar_path, dest)
|
||||
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
|
||||
raise Error, "tar extract failed" unless ok
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
# Decisione centralizzata: temporary (YouTube) | retained (HLS) | none.
|
||||
class StoragePolicy
|
||||
TEMPORARY = "temporary"
|
||||
RETAINED = "retained"
|
||||
NONE = "none"
|
||||
POLICIES = [TEMPORARY, RETAINED, NONE].freeze
|
||||
|
||||
def self.call(session)
|
||||
new(session).call
|
||||
end
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
team = @session.match.team
|
||||
ent = team.entitlements
|
||||
|
||||
return NONE unless ent.can_create_recordings?
|
||||
|
||||
if youtube_destination?
|
||||
TEMPORARY
|
||||
else
|
||||
RETAINED
|
||||
end
|
||||
end
|
||||
|
||||
def youtube_destination?
|
||||
@session.platform.to_s == "youtube"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,8 +9,9 @@ module Recordings
|
||||
def call
|
||||
recording = Recording.find_by(stream_session: @session)
|
||||
return unless recording&.status == "processing"
|
||||
return if recording.storage_key.present?
|
||||
|
||||
source_files = local_source_files
|
||||
source_files = collect_source_files
|
||||
if source_files.empty?
|
||||
fail_recording!(recording, "Nessun file di registrazione trovato")
|
||||
cleanup_mediamtx_path
|
||||
@@ -35,23 +36,52 @@ module Recordings
|
||||
error_message: nil
|
||||
)
|
||||
|
||||
Rails.logger.info(
|
||||
"[Recordings::UploadFromSession] ready recording=#{recording.id} " \
|
||||
"storage_policy=#{recording.storage_policy} key=#{storage_key} bytes=#{byte_size}"
|
||||
)
|
||||
|
||||
cleanup_local_sources(source_files, merged_path)
|
||||
cleanup_cloud_pull!
|
||||
cleanup_remote_recordings!
|
||||
cleanup_mediamtx_path
|
||||
Recordings::PostProcessJob.perform_async(recording.id)
|
||||
recording
|
||||
rescue Error, Recordings::Storage::Error => e
|
||||
rescue Error, Recordings::Storage::Error, Recordings::PullFromCloudNode::Error => e
|
||||
recording = Recording.find_by(stream_session: @session)
|
||||
fail_recording!(recording, e.message) if recording
|
||||
raise
|
||||
ensure
|
||||
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
|
||||
cleanup_cloud_pull! unless recording_ready?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collect_source_files
|
||||
files = local_source_files
|
||||
return files if files.any?
|
||||
|
||||
puller = Recordings::PullFromCloudNode.new(@session)
|
||||
return [] unless puller.applicable?
|
||||
|
||||
3.times do |i|
|
||||
cleanup_cloud_pull!
|
||||
@cloud_pull_dir = puller.fetch
|
||||
files = scan_recording_files(@cloud_pull_dir)
|
||||
return files if files.any?
|
||||
|
||||
sleep 2 if i < 2
|
||||
end
|
||||
[]
|
||||
end
|
||||
|
||||
def local_source_files
|
||||
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
|
||||
return [] unless Dir.exist?(base)
|
||||
scan_recording_files(File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name))
|
||||
end
|
||||
|
||||
def scan_recording_files(base)
|
||||
return [] if base.blank? || !Dir.exist?(base)
|
||||
|
||||
Dir.glob(File.join(base, "**", "*"))
|
||||
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
|
||||
@@ -83,8 +113,12 @@ module Recordings
|
||||
end
|
||||
|
||||
def object_key(recording)
|
||||
if recording.temporary_storage?
|
||||
"temporary_replays/teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
|
||||
else
|
||||
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
|
||||
end
|
||||
end
|
||||
|
||||
def cleanup_local_sources(source_files, merged_path)
|
||||
source_files.each { |path| FileUtils.rm_f(path) }
|
||||
@@ -100,6 +134,24 @@ module Recordings
|
||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||
end
|
||||
|
||||
def cleanup_cloud_pull!
|
||||
return if @cloud_pull_dir.blank? || !Dir.exist?(@cloud_pull_dir)
|
||||
|
||||
FileUtils.remove_entry(@cloud_pull_dir)
|
||||
@cloud_pull_dir = nil
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def cleanup_remote_recordings!
|
||||
Recordings::PullFromCloudNode.new(@session).cleanup_remote!
|
||||
end
|
||||
|
||||
def recording_ready?
|
||||
rec = Recording.find_by(stream_session: @session)
|
||||
rec&.status == "ready"
|
||||
end
|
||||
|
||||
def fail_recording!(recording, message)
|
||||
recording.update!(status: "failed", error_message: message)
|
||||
cleanup_mediamtx_path
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
# Collega la live YouTube (broadcast_id) al VOD e aggiorna metadata recording.
|
||||
class VerifyYoutubeReplay
|
||||
class Error < StandardError; end
|
||||
|
||||
Result = Struct.new(:status, :message, keyword_init: true) do
|
||||
def ok?
|
||||
status == :ok
|
||||
end
|
||||
|
||||
def retriable?
|
||||
status == :pending
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(recording)
|
||||
@recording = recording
|
||||
end
|
||||
|
||||
def call
|
||||
unless @recording.temporary_storage?
|
||||
return Result.new(status: :skipped, message: "not_temporary")
|
||||
end
|
||||
|
||||
if @recording.youtube_verified_at.present? && @recording.youtube_video_id.present?
|
||||
return Result.new(status: :ok, message: "already_verified")
|
||||
end
|
||||
|
||||
session = @recording.stream_session
|
||||
broadcast_id = session&.youtube_broadcast_id
|
||||
if broadcast_id.blank?
|
||||
Rails.logger.warn(
|
||||
"[Recordings::VerifyYoutubeReplay] missing broadcast_id recording=#{@recording.id}"
|
||||
)
|
||||
return Result.new(status: :failed, message: "missing_broadcast_id")
|
||||
end
|
||||
|
||||
info = Youtube::VodStatus.new(@recording.team, channel: "team").fetch(broadcast_id)
|
||||
unless info.ready
|
||||
Rails.logger.info(
|
||||
"[Recordings::VerifyYoutubeReplay] not_ready recording=#{@recording.id} " \
|
||||
"video=#{broadcast_id} upload_status=#{info.upload_status.inspect}"
|
||||
)
|
||||
return Result.new(status: :pending, message: "vod_not_ready")
|
||||
end
|
||||
|
||||
apply_verified!(info)
|
||||
Rails.logger.info(
|
||||
"[Recordings::VerifyYoutubeReplay] ok recording=#{@recording.id} " \
|
||||
"youtube_video_id=#{info.video_id}"
|
||||
)
|
||||
Result.new(status: :ok, message: "verified")
|
||||
rescue Youtube::VodStatus::Error => e
|
||||
Rails.logger.warn(
|
||||
"[Recordings::VerifyYoutubeReplay] api_error recording=#{@recording.id}: #{e.message}"
|
||||
)
|
||||
Result.new(status: :pending, message: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_verified!(info)
|
||||
meta = @recording.metadata.is_a?(Hash) ? @recording.metadata.deep_dup : {}
|
||||
yt = meta.fetch("youtube", {}).merge(
|
||||
"broadcast_id" => @recording.stream_session.youtube_broadcast_id,
|
||||
"thumbnail_url" => info.thumbnail_url,
|
||||
"privacy_status" => info.privacy_status,
|
||||
"upload_status" => info.upload_status,
|
||||
"verified_via" => "live_broadcast"
|
||||
).compact
|
||||
|
||||
attrs = {
|
||||
youtube_video_id: info.video_id,
|
||||
youtube_verified_at: Time.current,
|
||||
youtube_published_at: @recording.youtube_published_at || Time.current,
|
||||
metadata: meta.merge("youtube" => yt)
|
||||
}
|
||||
attrs[:duration_secs] = info.duration_secs if info.duration_secs.to_i.positive?
|
||||
attrs[:title] = info.title if info.title.present? && @recording.title.blank?
|
||||
|
||||
@recording.update!(attrs)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Sessions
|
||||
# Normalizza e applica fingerprint del client (OS, app, device, operatore)
|
||||
# sulla sessione, a create e/o a ogni telemetry.
|
||||
class ApplyClientInfo
|
||||
OS_VALUES = %w[android ios].freeze
|
||||
MAX_LEN = 80
|
||||
|
||||
ATTRS = %i[
|
||||
client_os app_version app_build device_manufacturer device_model os_version carrier
|
||||
].freeze
|
||||
|
||||
def self.call(session, raw)
|
||||
new(session, raw).call
|
||||
end
|
||||
|
||||
def initialize(session, raw)
|
||||
@session = session
|
||||
@raw = normalize_hash(raw)
|
||||
end
|
||||
|
||||
def call
|
||||
attrs = extract_attrs
|
||||
return @session if attrs.empty?
|
||||
|
||||
@session.assign_attributes(attrs)
|
||||
@session.save! if @session.persisted? && @session.changed?
|
||||
@session
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_hash(raw)
|
||||
return {} if raw.blank?
|
||||
|
||||
data = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
|
||||
data = data.to_h if data.respond_to?(:to_h)
|
||||
data.with_indifferent_access
|
||||
rescue StandardError
|
||||
{}
|
||||
end
|
||||
|
||||
def extract_attrs
|
||||
attrs = {}
|
||||
|
||||
os = @raw[:os].presence || @raw[:client_os].presence
|
||||
os = os.to_s.downcase.strip
|
||||
attrs[:client_os] = os if OS_VALUES.include?(os)
|
||||
|
||||
{
|
||||
app_version: %i[app_version version],
|
||||
app_build: %i[app_build build build_number],
|
||||
device_manufacturer: %i[device_manufacturer manufacturer],
|
||||
device_model: %i[device_model model],
|
||||
os_version: %i[os_version system_version],
|
||||
carrier: %i[carrier network_operator operator]
|
||||
}.each do |column, keys|
|
||||
value = keys.map { |k| @raw[k] }.find(&:present?)
|
||||
next if value.blank?
|
||||
|
||||
attrs[column] = truncate(value.to_s.strip)
|
||||
end
|
||||
|
||||
attrs
|
||||
end
|
||||
|
||||
def truncate(value)
|
||||
value.bytesize <= MAX_LEN ? value : value.byteslice(0, MAX_LEN)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -26,6 +26,7 @@ module Sessions
|
||||
target_fps: @params[:target_fps] || 30,
|
||||
status: "idle"
|
||||
)
|
||||
Sessions::ApplyClientInfo.call(session, @params[:client])
|
||||
|
||||
youtube_channel = nil
|
||||
if session.platform == "youtube"
|
||||
@@ -46,22 +47,56 @@ module Sessions
|
||||
{
|
||||
created: true,
|
||||
platform: session.platform,
|
||||
stream_node: session.stream_node&.slug
|
||||
}
|
||||
stream_node: session.stream_node&.slug,
|
||||
client_os: session.client_os,
|
||||
app_version: session.app_version,
|
||||
device_model: session.device_model
|
||||
}.compact
|
||||
)
|
||||
end
|
||||
|
||||
kick_autoscaler_if_soft_limit!
|
||||
|
||||
if session.platform == "youtube"
|
||||
YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel)
|
||||
end
|
||||
|
||||
session
|
||||
rescue Streams::NodeRegistry::NoCapacityError => e
|
||||
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
||||
raise_no_capacity!(e)
|
||||
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
||||
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
|
||||
rescue Mediamtx::Client::Error => e
|
||||
if e.message.to_s.match?(/failed to open|Connection refused|Timeout|timed out/i)
|
||||
raise Streams::IngestUnavailableError, e.message
|
||||
end
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def raise_no_capacity!(error)
|
||||
if Streams::Autoscaler.enabled?
|
||||
Streams::AutoscalerJob.kick!
|
||||
raise Teams::EntitlementError.new(
|
||||
"Capacità streaming in espansione. Riprova tra poco.",
|
||||
code: "stream_capacity_scaling"
|
||||
)
|
||||
end
|
||||
|
||||
raise Teams::EntitlementError.new(error.message, code: "stream_capacity_exhausted")
|
||||
end
|
||||
|
||||
def kick_autoscaler_if_soft_limit!
|
||||
return unless Streams::Autoscaler.enabled?
|
||||
|
||||
free = Streams::Autoscaler.metrics[:free_slots].to_i
|
||||
return if free > Streams::Autoscaler.soft_free_slots
|
||||
|
||||
Streams::AutoscalerJob.kick!
|
||||
end
|
||||
|
||||
|
||||
def assert_youtube_channel!(youtube_channel)
|
||||
resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel)
|
||||
if resolver.resolve.blank?
|
||||
|
||||
@@ -12,7 +12,7 @@ module Sessions
|
||||
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
||||
|
||||
recording = Recordings::FinalizeSession.new(@session).call
|
||||
Recordings::UploadJob.perform_async(@session.id) if recording
|
||||
Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
|
||||
remove_mediamtx_paths!
|
||||
|
||||
log_event("ended")
|
||||
|
||||
@@ -136,6 +136,8 @@ module Streams
|
||||
|
||||
def reconcile!
|
||||
actions = []
|
||||
actions.concat(promote_provisioning_nodes!)
|
||||
actions.concat(reclaim_stuck_provisioning!)
|
||||
m = self.class.metrics
|
||||
|
||||
if need_capacity?(m) && can_provision?(m)
|
||||
@@ -170,6 +172,30 @@ module Streams
|
||||
|
||||
private
|
||||
|
||||
def promote_provisioning_nodes!
|
||||
actions = []
|
||||
StreamNode.where(status: "provisioning").find_each do |node|
|
||||
next unless Streams::NodeHealth.promote_if_healthy!(node)
|
||||
|
||||
actions << :"ready_#{node.slug}"
|
||||
Rails.logger.info("[Streams::Autoscaler] promoted #{node.slug} to ready")
|
||||
end
|
||||
actions
|
||||
end
|
||||
|
||||
def reclaim_stuck_provisioning!
|
||||
actions = []
|
||||
stuck_after = ENV.fetch("STREAM_NODE_PROVISIONING_STUCK_MINUTES", "15").to_i.minutes.ago
|
||||
StreamNode.where(status: "provisioning").where("created_at < ?", stuck_after).find_each do |node|
|
||||
@provisioner.decommission!(node)
|
||||
actions << :"reclaim_#{node.slug}"
|
||||
Rails.logger.warn("[Streams::Autoscaler] decommissioned stuck provisioning #{node.slug}")
|
||||
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
|
||||
Rails.logger.warn("[Streams::Autoscaler] reclaim #{node.slug}: #{e.message}")
|
||||
end
|
||||
actions
|
||||
end
|
||||
|
||||
def need_capacity?(m)
|
||||
m[:free_slots] <= self.class.soft_free_slots
|
||||
end
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Streams
|
||||
# Ingest MediaMTX non raggiungibile (es. CPX ancora in bootstrap). API → 503 retryable.
|
||||
class IngestUnavailableError < StandardError
|
||||
attr_reader :code
|
||||
|
||||
def initialize(message = "Ingest temporaneamente non disponibile", code: "stream_ingest_unavailable")
|
||||
super(message)
|
||||
@code = code
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Streams
|
||||
# Probe reachability of MediaMTX API on a stream node (:9997).
|
||||
class NodeHealth
|
||||
def self.mediamtx_up?(node, timeout: 2)
|
||||
new(node, timeout: timeout).mediamtx_up?
|
||||
end
|
||||
|
||||
def self.promote_if_healthy!(node, timeout: 2)
|
||||
return false unless node.status == "provisioning"
|
||||
return false unless mediamtx_up?(node, timeout: timeout)
|
||||
|
||||
node.update!(status: "ready", last_health_at: Time.current)
|
||||
true
|
||||
end
|
||||
|
||||
def initialize(node, timeout: 2)
|
||||
@node = node
|
||||
@timeout = timeout
|
||||
end
|
||||
|
||||
def mediamtx_up?
|
||||
Mediamtx::Client.new(base_url: @node.api_base_url).reachable?(timeout: @timeout)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -80,11 +80,15 @@ module Streams
|
||||
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
|
||||
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
|
||||
|
||||
StreamNode.create!(
|
||||
# Simulated/lab che riusa MediaMTX home: subito ready. Cloud reale: provisioning
|
||||
# finché :9997 risponde (evita allocate → Faraday Connection refused → 500).
|
||||
initial_status = simulated ? "ready" : "provisioning"
|
||||
|
||||
node = StreamNode.create!(
|
||||
slug: slug,
|
||||
hostname: hostname,
|
||||
role: role,
|
||||
status: "ready",
|
||||
status: initial_status,
|
||||
provider: provider_name_for(cloud, role: role),
|
||||
provider_instance_id: instance.id,
|
||||
rtmp_base_url: urls.fetch(:rtmp_base_url),
|
||||
@@ -102,6 +106,34 @@ module Streams
|
||||
"cloud_raw" => instance.raw
|
||||
}
|
||||
)
|
||||
|
||||
wait_until_mediamtx_ready!(node) unless simulated
|
||||
node.reload
|
||||
end
|
||||
|
||||
def wait_until_mediamtx_ready!(node, timeout: nil, interval: nil)
|
||||
timeout ||= ENV.fetch("STREAM_NODE_READY_TIMEOUT_SECS", "180").to_i
|
||||
interval ||= ENV.fetch("STREAM_NODE_READY_POLL_SECS", "3").to_f
|
||||
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
||||
|
||||
loop do
|
||||
if Streams::NodeHealth.promote_if_healthy!(node)
|
||||
Rails.logger.info("[Streams::NodeProvisioner] #{node.slug} MediaMTX ready")
|
||||
return node
|
||||
end
|
||||
|
||||
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||
if remaining <= 0
|
||||
Rails.logger.warn(
|
||||
"[Streams::NodeProvisioner] #{node.slug} ancora provisioning dopo #{timeout}s " \
|
||||
"(api=#{node.api_base_url}); autoscaler continuerà a promuovere"
|
||||
)
|
||||
return node
|
||||
end
|
||||
|
||||
sleep([interval, remaining].min)
|
||||
node.reload
|
||||
end
|
||||
end
|
||||
|
||||
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Youtube
|
||||
# Stato VOD dopo una live (broadcast_id ≈ video_id su YouTube).
|
||||
class VodStatus
|
||||
class Error < StandardError; end
|
||||
|
||||
Result = Struct.new(
|
||||
:ready,
|
||||
:video_id,
|
||||
:title,
|
||||
:duration_secs,
|
||||
:thumbnail_url,
|
||||
:privacy_status,
|
||||
:upload_status,
|
||||
keyword_init: true
|
||||
)
|
||||
|
||||
def initialize(team, channel: "team")
|
||||
@team = team
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def fetch(video_id)
|
||||
raise Error, "video_id mancante" if video_id.blank?
|
||||
|
||||
if mock_or_unconfigured?(video_id)
|
||||
return Result.new(
|
||||
ready: true,
|
||||
video_id: video_id,
|
||||
title: nil,
|
||||
duration_secs: nil,
|
||||
thumbnail_url: nil,
|
||||
privacy_status: "unlisted",
|
||||
upload_status: "processed"
|
||||
)
|
||||
end
|
||||
|
||||
client = authorized_client
|
||||
item = client.list_videos("snippet,contentDetails,status", id: video_id).items&.first
|
||||
return Result.new(ready: false, video_id: video_id) if item.blank?
|
||||
|
||||
upload_status = item.status&.upload_status.to_s
|
||||
ready = upload_status.in?(%w[processed uploaded]) ||
|
||||
(item.snippet.present? && upload_status != "deleted" && upload_status != "rejected" && upload_status != "failed")
|
||||
|
||||
Result.new(
|
||||
ready: ready,
|
||||
video_id: item.id,
|
||||
title: item.snippet&.title,
|
||||
duration_secs: parse_duration(item.content_details&.duration),
|
||||
thumbnail_url: item.snippet&.thumbnails&.high&.url || item.snippet&.thumbnails&.default&.url,
|
||||
privacy_status: item.status&.privacy_status,
|
||||
upload_status: upload_status
|
||||
)
|
||||
rescue Google::Apis::Error => e
|
||||
raise Error, e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mock_or_unconfigured?(video_id)
|
||||
video_id.to_s.start_with?("mock_") ||
|
||||
ENV["YOUTUBE_CLIENT_ID"].blank? ||
|
||||
CredentialResolver.new(@team, channel: @channel).resolve.blank?
|
||||
end
|
||||
|
||||
def authorized_client
|
||||
credential = CredentialResolver.new(@team, channel: @channel).resolve
|
||||
raise Error, "Credenziali YouTube non disponibili" if credential.blank?
|
||||
|
||||
OauthRefresh.new(credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
|
||||
end
|
||||
|
||||
def parse_duration(iso)
|
||||
return nil if iso.blank?
|
||||
|
||||
# PT1H2M3S
|
||||
match = iso.match(/\APT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?\z/)
|
||||
return nil unless match
|
||||
|
||||
match[1].to_i * 3600 + match[2].to_i * 60 + match[3].to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.analytics.index.title") %></h2>
|
||||
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-sessions-filters">
|
||||
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||
<div class="admin-filter-grid">
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.from") %></span>
|
||||
<%= date_field_tag :from, @filters[:from] %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.to") %></span>
|
||||
<%= date_field_tag :to, @filters[:to] %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.device") %></span>
|
||||
<%= select_tag :device,
|
||||
options_for_select(
|
||||
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
|
||||
@filters[:device]
|
||||
) %>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-filter-actions">
|
||||
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||
<%= link_to t("admin.analytics.filters.reset"), admin_analytics_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<% if @pages.any? %>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("admin.analytics.index.table.path") %></th>
|
||||
<th><%= t("admin.analytics.index.table.pageviews") %></th>
|
||||
<th><%= t("admin.analytics.index.table.moves") %></th>
|
||||
<th><%= t("admin.analytics.index.table.clicks") %></th>
|
||||
<th><%= t("admin.analytics.index.table.avg_scroll") %></th>
|
||||
<th><%= t("admin.analytics.index.table.max_scroll") %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @pages.each do |row| %>
|
||||
<% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %>
|
||||
<tr>
|
||||
<td><code class="admin-mono"><%= row[:page_path] %></code></td>
|
||||
<td><%= row[:pageviews] %></td>
|
||||
<td><%= row[:moves] %></td>
|
||||
<td><%= row[:clicks] %></td>
|
||||
<td class="muted"><%= avg %>%</td>
|
||||
<td class="muted"><%= row[:max_scroll] %>%</td>
|
||||
<td>
|
||||
<%= link_to t("admin.analytics.index.heatmap"),
|
||||
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.analytics.index.none") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,138 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head admin-session-head">
|
||||
<div>
|
||||
<p class="muted admin-page-sub">
|
||||
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
||||
</p>
|
||||
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
|
||||
<p><code class="admin-mono"><%= @page_path %></code></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-sessions-filters">
|
||||
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||
<%= hidden_field_tag :page_path, @page_path %>
|
||||
<div class="admin-filter-grid">
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.from") %></span>
|
||||
<%= date_field_tag :from, @filters[:from] %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.to") %></span>
|
||||
<%= date_field_tag :to, @filters[:to] %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.device") %></span>
|
||||
<%= select_tag :device,
|
||||
options_for_select(
|
||||
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
|
||||
@filters[:device]
|
||||
) %>
|
||||
</label>
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.analytics.filters.layer") %></span>
|
||||
<%= select_tag :layer,
|
||||
options_for_select(
|
||||
[
|
||||
[t("admin.analytics.filters.layer_move"), "move"],
|
||||
[t("admin.analytics.filters.layer_click"), "click"]
|
||||
],
|
||||
@filters[:layer]
|
||||
) %>
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-filter-actions">
|
||||
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="admin-session-grid">
|
||||
<section class="panel">
|
||||
<h3><%= t("admin.analytics.show.summary") %></h3>
|
||||
<dl class="admin-kv">
|
||||
<div>
|
||||
<dt><%= t("admin.analytics.index.table.pageviews") %></dt>
|
||||
<dd><%= @pageviews %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.analytics.index.table.moves") %></dt>
|
||||
<dd><%= @move_total %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.analytics.index.table.clicks") %></dt>
|
||||
<dd><%= @click_total %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.analytics.index.table.avg_scroll") %></dt>
|
||||
<dd><%= @avg_scroll %>%</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.analytics.index.table.max_scroll") %></dt>
|
||||
<dd><%= @max_scroll %>%</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel" style="grid-column: span 2;">
|
||||
<h3><%= t("admin.analytics.show.scroll_depth") %></h3>
|
||||
<div class="admin-scroll-bar" aria-hidden="true">
|
||||
<div class="admin-scroll-bar__avg" style="width: <%= @avg_scroll %>%"></div>
|
||||
<div class="admin-scroll-bar__max" style="left: <%= @max_scroll %>%"></div>
|
||||
</div>
|
||||
<p class="muted admin-table-sub">
|
||||
<%= t("admin.analytics.show.scroll_hint", avg: @avg_scroll, max: @max_scroll) %>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<h3>
|
||||
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
|
||||
</h3>
|
||||
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p>
|
||||
|
||||
<% if @cells.any? %>
|
||||
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
||||
<% if @snapshot&.image&.attached? %>
|
||||
<div
|
||||
class="admin-heatmap-scaler"
|
||||
id="admin-heatmap-scaler"
|
||||
data-design-width="<%= [@snapshot.width, 1].max %>"
|
||||
data-design-height="<%= [@snapshot.height, 1].max %>"
|
||||
>
|
||||
<%= image_tag url_for(@snapshot.image),
|
||||
class: "admin-heatmap-shot",
|
||||
id: "admin-heatmap-shot",
|
||||
alt: t("admin.analytics.show.preview_title") %>
|
||||
<canvas
|
||||
id="admin-heatmap"
|
||||
class="admin-heatmap-overlay"
|
||||
data-grid="<%= @grid %>"
|
||||
data-max="<%= [@max_weight, 1].max %>"
|
||||
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
|
||||
></canvas>
|
||||
</div>
|
||||
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
|
||||
<%= t("admin.analytics.show.snapshot_meta",
|
||||
device: @snapshot.device,
|
||||
at: l(@snapshot.captured_at, format: :short)) %>
|
||||
</p>
|
||||
<% else %>
|
||||
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
||||
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
|
||||
<canvas
|
||||
id="admin-heatmap"
|
||||
class="admin-heatmap-overlay"
|
||||
data-grid="<%= @grid %>"
|
||||
data-max="<%= [@max_weight, 1].max %>"
|
||||
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
|
||||
></canvas>
|
||||
<% end %>
|
||||
</div>
|
||||
<script src="/admin-analytics-heatmap.js?v=4" defer></script>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.analytics.show.no_points") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
@@ -107,6 +107,7 @@
|
||||
<tr>
|
||||
<th><%= t("admin.dashboard.sessions.table.match") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.status") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.client") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.ingest") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.start") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.link") %></th>
|
||||
@@ -118,6 +119,7 @@
|
||||
<tr>
|
||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
||||
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
|
||||
<td class="muted"><%= admin_session_client_summary(s) %></td>
|
||||
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
|
||||
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
|
||||
<td>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<% node = session.stream_node %>
|
||||
<% if node %>
|
||||
<% slug = session.ingest_slug_display %>
|
||||
<% role = session.ingest_role_display %>
|
||||
<% if slug.present? %>
|
||||
<div class="admin-ingest">
|
||||
<code class="admin-ingest__slug"><%= node.slug %></code>
|
||||
<span class="badge <%= admin_session_ingest_badge_class(node) %>"><%= admin_session_ingest_role_label(node) %></span>
|
||||
<code class="admin-ingest__slug"><%= slug %></code>
|
||||
<% if role.present? %>
|
||||
<span class="badge <%= admin_session_ingest_badge_class(role) %>"><%= admin_session_ingest_role_label(role) %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
|
||||
|
||||
@@ -1,10 +1,89 @@
|
||||
<h2><%= t("admin.sessions.index.title") %></h2>
|
||||
<table class="admin-table">
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.sessions.index.title") %></h2>
|
||||
<p class="muted admin-page-sub"><%= t("admin.sessions.index.lead") %></p>
|
||||
</div>
|
||||
|
||||
<div class="panel admin-sessions-filters">
|
||||
<%= form_with url: admin_sessions_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||
<div class="admin-filter-grid">
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.sessions.index.filters.q") %></span>
|
||||
<%= text_field_tag :q, @filters[:q], placeholder: t("admin.sessions.index.filters.q_placeholder") %>
|
||||
</label>
|
||||
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.sessions.index.filters.status") %></span>
|
||||
<%= select_tag :status,
|
||||
options_for_select(
|
||||
[[t("admin.sessions.index.filters.any"), ""]] + StreamSession::STATUSES.map { |s| [s, s] },
|
||||
@filters[:status]
|
||||
) %>
|
||||
</label>
|
||||
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.sessions.index.filters.platform") %></span>
|
||||
<%= select_tag :platform,
|
||||
options_for_select(
|
||||
[[t("admin.sessions.index.filters.any"), ""]] + StreamSession::LIVE_PLATFORMS.map { |p| [p, p] },
|
||||
@filters[:platform]
|
||||
) %>
|
||||
</label>
|
||||
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.sessions.index.filters.club") %></span>
|
||||
<%= select_tag :club_id,
|
||||
options_for_select(
|
||||
[[t("admin.sessions.index.filters.any"), ""]] + @clubs.map { |c| [c.name, c.id] },
|
||||
@filters[:club_id]
|
||||
) %>
|
||||
</label>
|
||||
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.sessions.index.filters.node") %></span>
|
||||
<%= select_tag :stream_node_id,
|
||||
options_for_select(
|
||||
[[t("admin.sessions.index.filters.any"), ""]] + @nodes.map { |n| ["#{n.slug} (#{n.role})", n.id] },
|
||||
@filters[:stream_node_id]
|
||||
) %>
|
||||
</label>
|
||||
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.sessions.index.filters.from") %></span>
|
||||
<%= date_field_tag :from, @filters[:from] %>
|
||||
</label>
|
||||
|
||||
<label class="admin-filter-field">
|
||||
<span><%= t("admin.sessions.index.filters.to") %></span>
|
||||
<%= date_field_tag :to, @filters[:to] %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="admin-filter-actions">
|
||||
<%= submit_tag t("admin.sessions.index.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||
<%= link_to t("admin.sessions.index.filters.reset"), admin_sessions_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||
<span class="muted admin-filter-count">
|
||||
<%= t("admin.sessions.index.results", shown: @sessions.size, total: @total_count) %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<% if @sessions.any? %>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table admin-table--sessions">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("admin.sessions.index.table.club") %></th>
|
||||
<th><%= t("admin.sessions.index.table.match") %></th>
|
||||
<th><%= t("admin.sessions.index.table.status") %></th>
|
||||
<th><%= t("admin.sessions.index.table.started") %></th>
|
||||
<th><%= t("admin.sessions.index.table.ended") %></th>
|
||||
<th><%= t("admin.sessions.index.table.duration") %></th>
|
||||
<th><%= t("admin.sessions.index.table.ingest") %></th>
|
||||
<th><%= t("admin.sessions.index.table.client") %></th>
|
||||
<th><%= t("admin.sessions.index.table.disconnects") %></th>
|
||||
<th><%= t("admin.sessions.index.table.link") %></th>
|
||||
<th></th>
|
||||
@@ -12,10 +91,27 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @sessions.each do |s| %>
|
||||
<% club = s.match.team.club %>
|
||||
<tr>
|
||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
||||
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
|
||||
<td>
|
||||
<% if club %>
|
||||
<%= link_to club.name, admin_club_path(club), class: "admin-table-strong" %>
|
||||
<% else %>
|
||||
<span class="muted"><%= t("admin.common.dash") %></span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<div class="admin-table-strong"><%= s.match.team.name %> vs <%= s.match.opponent_name %></div>
|
||||
<div class="muted admin-table-sub"><%= s.platform %></div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <%= admin_session_status_badge_class(s.status) %>"><%= s.status %></span>
|
||||
</td>
|
||||
<td class="muted"><%= admin_datetime(s.started_at || s.created_at) %></td>
|
||||
<td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td>
|
||||
<td class="muted"><%= admin_session_duration_label(s) %></td>
|
||||
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
|
||||
<td class="muted admin-table-sub"><%= admin_session_client_summary(s) %></td>
|
||||
<td><%= s.disconnection_count %></td>
|
||||
<td>
|
||||
<div class="admin-link-compact">
|
||||
@@ -30,3 +126,8 @@
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.sessions.index.none") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,35 +1,268 @@
|
||||
<h2><%= t("admin.sessions.show.title", id: @session.id) %></h2>
|
||||
<p><%= t("admin.sessions.show.status_label") %> <strong><%= @session.status %></strong></p>
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
<% team = @session.match.team %>
|
||||
<% club = @club || team.club %>
|
||||
|
||||
<div class="admin-page-head admin-session-head">
|
||||
<div>
|
||||
<p class="muted admin-page-sub">
|
||||
<%= link_to t("admin.sessions.show.back"), admin_sessions_path %>
|
||||
</p>
|
||||
<h2 class="admin-page-title"><%= t("admin.sessions.show.heading") %></h2>
|
||||
<p class="admin-session-match">
|
||||
<strong><%= team.name %></strong>
|
||||
<span class="muted">vs</span>
|
||||
<strong><%= @session.match.opponent_name %></strong>
|
||||
</p>
|
||||
<% if club %>
|
||||
<p class="muted">
|
||||
<%= t("admin.sessions.show.club_label") %>
|
||||
<%= link_to club.name, admin_club_path(club) %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="admin-session-head-actions">
|
||||
<span class="badge <%= admin_session_status_badge_class(@session.status) %>"><%= @session.status %></span>
|
||||
<% unless @session.terminal? %>
|
||||
<p>
|
||||
<%= button_to t("admin.sessions.show.stop_button"),
|
||||
stop_admin_session_path(@session),
|
||||
method: :post,
|
||||
class: "admin-btn admin-btn--danger",
|
||||
class: "admin-btn admin-btn--danger admin-btn--sm",
|
||||
form: { data: { turbo_confirm: t("admin.sessions.show.stop_confirm") } } %>
|
||||
</p>
|
||||
<% end %>
|
||||
<p><%= t("admin.sessions.show.match_label", team: @session.match.team.name, opponent: @session.match.opponent_name) %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render "admin/sessions/links", session: @session %>
|
||||
<div class="admin-session-grid">
|
||||
<section class="panel">
|
||||
<h3><%= t("admin.sessions.show.summary_title") %></h3>
|
||||
<dl class="admin-kv">
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.id") %></dt>
|
||||
<dd><code class="admin-mono"><%= @session.id %></code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.club") %></dt>
|
||||
<dd><%= club&.name || t("admin.common.dash") %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.team") %></dt>
|
||||
<dd><%= team.name %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.opponent") %></dt>
|
||||
<dd><%= @session.match.opponent_name %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.operator") %></dt>
|
||||
<dd>
|
||||
<% if @session.user %>
|
||||
<%= @session.user.name.presence || @session.user.email %>
|
||||
<span class="muted">(<%= @session.user.email %>)</span>
|
||||
<% else %>
|
||||
<%= t("admin.common.dash") %>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.platform") %></dt>
|
||||
<dd><%= @session.platform %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.client_os") %></dt>
|
||||
<dd><%= admin_session_client_os_label(@session) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.app_version") %></dt>
|
||||
<dd>
|
||||
<% if @session.app_version.present? %>
|
||||
<%= @session.app_version %>
|
||||
<% if @session.app_build.present? %>
|
||||
<span class="muted">(<%= @session.app_build %>)</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= t("admin.common.dash") %>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.device") %></dt>
|
||||
<dd>
|
||||
<% device = [@session.device_manufacturer, @session.device_model].compact_blank.join(" ") %>
|
||||
<%= device.presence || t("admin.common.dash") %>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.os_version") %></dt>
|
||||
<dd><%= @session.os_version.presence || t("admin.common.dash") %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.carrier") %></dt>
|
||||
<dd><%= @session.carrier.presence || t("admin.common.dash") %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.privacy") %></dt>
|
||||
<dd><%= @session.privacy_status %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.quality") %></dt>
|
||||
<dd><%= @session.quality_preset %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.min_quality") %></dt>
|
||||
<dd><%= @session.min_quality_preset %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.audio") %></dt>
|
||||
<dd><%= @session.audio_muted? ? t("admin.sessions.show.audio_muted") : t("admin.sessions.show.audio_on") %></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<% if @session.youtube_broadcast_id %>
|
||||
<p><%= t("admin.sessions.show.youtube_studio") %>: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener"><%= t("admin.sessions.show.broadcast") %></a></p>
|
||||
<section class="panel">
|
||||
<h3><%= t("admin.sessions.show.timing_title") %></h3>
|
||||
<dl class="admin-kv">
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.created") %></dt>
|
||||
<dd><%= admin_datetime(@session.created_at, with_seconds: true) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.started") %></dt>
|
||||
<dd><%= admin_datetime(@session.started_at, with_seconds: true) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.ended") %></dt>
|
||||
<dd><%= admin_datetime(@session.ended_at, with_seconds: true) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.duration") %></dt>
|
||||
<dd><%= admin_session_duration_label(@session) %></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.disconnects") %></dt>
|
||||
<dd><%= @session.disconnection_count %></dd>
|
||||
</div>
|
||||
<% if @session.match.scheduled_at %>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.scheduled") %></dt>
|
||||
<dd><%= admin_datetime(@session.match.scheduled_at) %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @session.match.location.present? %>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.location") %></dt>
|
||||
<dd><%= @session.match.location %></dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h3><%= t("admin.sessions.show.ingest_title") %></h3>
|
||||
<dl class="admin-kv">
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.node") %></dt>
|
||||
<dd>
|
||||
<% slug = @session.ingest_slug_display %>
|
||||
<% role = @session.ingest_role_display %>
|
||||
<% if slug.present? %>
|
||||
<code><%= slug %></code>
|
||||
<% if role.present? %>
|
||||
<span class="badge <%= admin_session_ingest_badge_class(role) %>">
|
||||
<%= admin_session_ingest_role_label(role) %>
|
||||
</span>
|
||||
<% end %>
|
||||
<p>
|
||||
<%= t("admin.sessions.show.ingest_node") %>
|
||||
<% if @session.stream_node %>
|
||||
<code><%= @session.stream_node.slug %></code>
|
||||
<span class="badge <%= admin_session_ingest_badge_class(@session.stream_node) %>"><%= admin_session_ingest_role_label(@session.stream_node) %></span>
|
||||
<span class="muted">(<%= @session.stream_node.provider %>)</span>
|
||||
<% else %>
|
||||
<span class="muted"><%= t("admin.sessions.ingest.decommissioned") %></span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p><%= t("admin.sessions.show.rtmp_ingest") %> <code><%= @session.rtmp_ingest_url %></code></p>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.rtmp") %></dt>
|
||||
<dd><code class="admin-mono admin-mono--wrap"><%= @session.rtmp_ingest_url %></code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.hls") %></dt>
|
||||
<dd><code class="admin-mono admin-mono--wrap"><%= @session.hls_playback_url %></code></dd>
|
||||
</div>
|
||||
<% if @session.youtube_broadcast_id.present? %>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.youtube_studio") %></dt>
|
||||
<dd>
|
||||
<a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener">
|
||||
<%= t("admin.sessions.show.broadcast") %>
|
||||
</a>
|
||||
<span class="muted">(<%= @session.youtube_broadcast_id %>)</span>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @session.recording %>
|
||||
<div>
|
||||
<dt><%= t("admin.sessions.show.fields.recording") %></dt>
|
||||
<dd>
|
||||
<span class="badge badge--<%= @session.recording.status == 'ready' ? 'ready' : 'paused' %>">
|
||||
<%= @session.recording.status %>
|
||||
</span>
|
||||
<% if club %>
|
||||
<%= link_to t("admin.sessions.show.open_replays"), admin_club_recordings_path(club) %>
|
||||
<% end %>
|
||||
</dd>
|
||||
</div>
|
||||
<% end %>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<%= render "admin/sessions/links", session: @session %>
|
||||
|
||||
<% if @session.device_states.any? %>
|
||||
<section class="panel">
|
||||
<h3><%= t("admin.sessions.show.devices_title") %></h3>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("admin.sessions.show.devices.role") %></th>
|
||||
<th><%= t("admin.sessions.show.devices.network") %></th>
|
||||
<th><%= t("admin.sessions.show.devices.battery") %></th>
|
||||
<th><%= t("admin.sessions.show.devices.bitrate") %></th>
|
||||
<th><%= t("admin.sessions.show.devices.fps") %></th>
|
||||
<th><%= t("admin.sessions.show.devices.last_seen") %></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @session.device_states.order(:device_role).each do |device| %>
|
||||
<tr>
|
||||
<td><%= device.device_role %></td>
|
||||
<td class="muted"><%= device.network_type.presence || t("admin.common.dash") %></td>
|
||||
<td class="muted"><%= device.battery_level.nil? ? t("admin.common.dash") : "#{device.battery_level}%" %></td>
|
||||
<td class="muted">
|
||||
<% if device.current_bitrate || device.target_bitrate %>
|
||||
<%= device.current_bitrate || t("admin.common.dash") %>
|
||||
<span class="muted">/ <%= device.target_bitrate || t("admin.common.dash") %></span>
|
||||
<% else %>
|
||||
<%= t("admin.common.dash") %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="muted"><%= device.fps || t("admin.common.dash") %></td>
|
||||
<td class="muted"><%= admin_datetime(device.last_seen_at, with_seconds: true) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<section class="panel">
|
||||
<h3><%= t("admin.sessions.show.events_title") %></h3>
|
||||
<table>
|
||||
<% if @events.any? %>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table admin-table--events">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("admin.sessions.show.table.type") %></th>
|
||||
@@ -40,10 +273,15 @@
|
||||
<tbody>
|
||||
<% @events.each do |e| %>
|
||||
<tr>
|
||||
<td><%= e.event_type %></td>
|
||||
<td><%= e.occurred_at %></td>
|
||||
<td><%= e.metadata.to_json %></td>
|
||||
<td><span class="admin-event-type"><%= e.event_type %></span></td>
|
||||
<td class="muted"><%= admin_datetime(e.occurred_at, with_seconds: true) %></td>
|
||||
<td><%= admin_format_event_meta(e.metadata) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.sessions.show.events_none") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<title><%= t("admin.layout.title") %></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="stylesheet" href="/admin.css?v=11">
|
||||
<%= csrf_meta_tags %>
|
||||
<link rel="stylesheet" href="/admin.css?v=15">
|
||||
<% if content_for?(:replay_archive_styles) %>
|
||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||
<% end %>
|
||||
@@ -30,6 +31,7 @@
|
||||
<%= 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") %>
|
||||
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
|
||||
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
||||
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
||||
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<meta name="application-name" content="Match Live TV">
|
||||
<%= csrf_meta_tags %>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<%= yield :head %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=68">
|
||||
<link rel="stylesheet" href="/marketing.css?v=76">
|
||||
</head>
|
||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
@@ -25,6 +26,7 @@
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=7" defer></script>
|
||||
<script src="/cookie-consent.js?v=1" defer></script>
|
||||
<script src="/site-analytics.js?v=4" defer></script>
|
||||
<script src="/cookie-consent.js?v=2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= csrf_meta_tags %>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=68">
|
||||
<link rel="stylesheet" href="/marketing.css?v=76">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
@@ -20,6 +21,7 @@
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=7" defer></script>
|
||||
<script src="/cookie-consent.js?v=1" defer></script>
|
||||
<script src="/site-analytics.js?v=4" defer></script>
|
||||
<script src="/cookie-consent.js?v=2" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap" aria-labelledby="contacts-social-title">
|
||||
<div class="features-panel contacts-social">
|
||||
<h2 id="contacts-social-title"><%= t("pages.contacts.social_title") %></h2>
|
||||
<%= render "shared/social_links", modifier: "panel" %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap" aria-labelledby="contacts-form-title">
|
||||
<div class="contacts-form-panel">
|
||||
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
|
||||
|
||||
@@ -101,6 +101,12 @@
|
||||
<td><%= t("legal.cookies.s4_2_row3_duration") %></td>
|
||||
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><%= raw t("legal.cookies.s4_2_row4_name_html") %></td>
|
||||
<td><%= t("legal.cookies.s4_2_row4_purpose") %></td>
|
||||
<td><%= t("legal.cookies.s4_2_row4_duration") %></td>
|
||||
<td><%= t("legal.cookies.s4_2_row4_provider") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,33 @@
|
||||
<h2><%= t("replay.show.processing_title") %></h2>
|
||||
<p><%= t("replay.show.processing_body") %></p>
|
||||
</div>
|
||||
<% elsif @recording.ready? && @recording.replay_source == "youtube" && @recording.youtube_video_id.present? && !@recording.youtube_video_id.to_s.start_with?("mock_") %>
|
||||
<div class="live-player-wrap live-player-wrap--embed">
|
||||
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
|
||||
title="<%= @recording.title_or_default %>"
|
||||
class="live-player-embed"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen></iframe>
|
||||
<%= render "public/live/player_overlays",
|
||||
match: @match,
|
||||
session: @session,
|
||||
stream_closed: true,
|
||||
on_air: false,
|
||||
badge_label: t("replay.show.badge_label"),
|
||||
badge_class: "badge-ended" %>
|
||||
</div>
|
||||
<p class="replay-show__meta-line">
|
||||
<%= l_local(@recording.recorded_at_or_fallback) %>
|
||||
· <%= t("replay.show.meta_line_duration", value: @recording.duration_label) %>
|
||||
· <%= @recording.views_label %>
|
||||
<% if @recording.source_platform_label != "—" %>
|
||||
· <%= @recording.source_platform_label %>
|
||||
<% end %>
|
||||
</p>
|
||||
<p class="replay-show__meta-line">
|
||||
<%= t("replay.show.youtube_only_body") %>
|
||||
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
|
||||
</p>
|
||||
<% elsif @recording.ready? && @recording.storage_key.present? %>
|
||||
<div class="live-player-wrap">
|
||||
<video id="replay-player" controls playsinline preload="metadata"
|
||||
@@ -43,9 +70,9 @@
|
||||
</p>
|
||||
|
||||
<% ent = @recording.team.entitlements %>
|
||||
<% if (ent.phone_download_enabled? && (logged_in? || @recording.unlisted?)) || @recording.youtube_watch_url %>
|
||||
<% if (ent.phone_download_enabled? && @recording.storage_key.present? && (logged_in? || @recording.unlisted?)) || @recording.youtube_watch_url %>
|
||||
<div class="replay-show__actions">
|
||||
<% if ent.phone_download_enabled? && (logged_in? || @recording.unlisted?) %>
|
||||
<% if ent.phone_download_enabled? && @recording.storage_key.present? && (logged_in? || @recording.unlisted?) %>
|
||||
<%= link_to t("replay.show.download_link"), public_replay_download_path(@session), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<% if @recording.youtube_watch_url %>
|
||||
@@ -53,25 +80,6 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% elsif @recording.ready? && @recording.youtube_watch_url.present? %>
|
||||
<div class="live-player-wrap live-player-wrap--embed">
|
||||
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
|
||||
title="<%= @recording.title_or_default %>"
|
||||
class="live-player-embed"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen></iframe>
|
||||
<%= render "public/live/player_overlays",
|
||||
match: @match,
|
||||
session: @session,
|
||||
stream_closed: true,
|
||||
on_air: false,
|
||||
badge_label: t("replay.show.badge_label"),
|
||||
badge_class: "badge-ended" %>
|
||||
</div>
|
||||
<p class="replay-show__meta-line">
|
||||
<%= t("replay.show.youtube_only_body") %>
|
||||
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
|
||||
</p>
|
||||
<% elsif @recording.ready? %>
|
||||
<div class="stream-ended" role="status">
|
||||
<h2><%= t("replay.show.file_missing_title") %></h2>
|
||||
|
||||
@@ -158,16 +158,16 @@
|
||||
<% end %>
|
||||
<span class="visually-hidden"><%= privacy_label %></span>
|
||||
<% end %>
|
||||
<% if ent.phone_download_enabled? && rec.ready? %>
|
||||
<% if ent.phone_download_enabled? && rec.ready? && rec.storage_key.present? %>
|
||||
<%= link_to "MP4", public_replay_download_path(rec.stream_session_id), class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.download_mp4_title") %>
|
||||
<% end %>
|
||||
<% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.youtube_video_id.blank? %>
|
||||
<% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.storage_key.present? && rec.youtube_video_id.blank? %>
|
||||
<%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: t("recordings.archive.publish_youtube_title"), form: { class: "replay-archive__action-form" } %>
|
||||
<% elsif rec.youtube_watch_url %>
|
||||
<%= link_to "YT", rec.youtube_watch_url, class: "replay-archive__action replay-archive__action--secondary", target: "_blank", rel: "noopener", title: t("recordings.archive.open_youtube_title") %>
|
||||
<% end %>
|
||||
<% has_youtube = rec.youtube_video_id.present? && !rec.youtube_video_id.to_s.start_with?("mock_") %>
|
||||
<% has_site = rec.storage_key.present? || %w[ready processing failed].include?(rec.status) %>
|
||||
<% has_site = rec.available_in_archive? || %w[processing failed].include?(rec.status) %>
|
||||
<% delete_confirm = t("recordings.archive.delete_confirm") %>
|
||||
<%= button_to paths.destroy.call(rec), method: :delete,
|
||||
params: filter_params,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<div class="site-footer__brand">
|
||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||
<%= render "shared/store_badges", variant: "footer" %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="site-footer__nav">
|
||||
<%= link_to t("common.contacts"), public_contatti_path %> ·
|
||||
<%= link_to t("common.support"), public_support_path %> ·
|
||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||
@@ -15,6 +14,10 @@
|
||||
<%= link_to t("common.terms"), public_termini_path %>
|
||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||
</div>
|
||||
<div class="site-footer__apps">
|
||||
<%= render "shared/store_badges", variant: "footer" %>
|
||||
<%= render "shared/social_links", modifier: "footer" %>
|
||||
</div>
|
||||
<div class="site-footer__legal">
|
||||
<p><%= t("footer.copyright") %></p>
|
||||
<p><%= t("footer.responsibility") %></p>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<div class="site-footer__brand">
|
||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="site-footer__nav">
|
||||
<%= link_to t("common.support"), public_support_path %> ·
|
||||
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||
<%= link_to t("common.terms"), public_termini_path %>
|
||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||
</div>
|
||||
<div class="site-footer__apps">
|
||||
<%= render "shared/social_links", modifier: "footer" %>
|
||||
</div>
|
||||
<div class="site-footer__legal">
|
||||
<p><%= t("footer.copyright") %></p>
|
||||
<p><%= t("footer.responsibility") %></p>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<%# Social links: Facebook, Instagram, TikTok, YouTube — glyph brand-color su sfondo trasparente. %>
|
||||
<% urls = {
|
||||
facebook: "https://www.facebook.com/matchlivetvapp",
|
||||
instagram: "https://www.instagram.com/matchlivetv_app/",
|
||||
tiktok: "https://www.tiktok.com/@matchlivetv_app",
|
||||
youtube: "https://www.youtube.com/@SportMatchLiveTv"
|
||||
} %>
|
||||
<% ig_grad_id = "ig-grad-#{SecureRandom.hex(4)}" %>
|
||||
<nav class="site-social<%= local_assigns[:modifier].present? ? " site-social--#{modifier}" : "" %>" aria-label="<%= t("footer.social_nav") %>">
|
||||
<a class="site-social__link site-social__link--facebook" href="<%= urls[:facebook] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_facebook") %>">
|
||||
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M14 8.2h2.2V5h-2.2C11.7 5 10 6.8 10 9.2V11H8v3.2h2V19h3.2v-4.8h2.2l.6-3.2h-2.8V9.2c0-.6.4-1 1-1z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="site-social__link site-social__link--instagram" href="<%= urls[:instagram] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_instagram") %>">
|
||||
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
|
||||
<defs>
|
||||
<linearGradient id="<%= ig_grad_id %>" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#f58529"/>
|
||||
<stop offset="45%" stop-color="#dd2a7b"/>
|
||||
<stop offset="100%" stop-color="#515bd4"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#<%= ig_grad_id %>)" d="M12 7.2A4.8 4.8 0 1 0 12 16.8 4.8 4.8 0 0 0 12 7.2zm0 7.7a2.9 2.9 0 1 1 0-5.8 2.9 2.9 0 0 1 0 5.8z"/>
|
||||
<circle fill="url(#<%= ig_grad_id %>)" cx="17.4" cy="6.7" r="1.1"/>
|
||||
<path fill="url(#<%= ig_grad_id %>)" d="M12 2.5c-2.6 0-2.9 0-3.9.1-2.6.1-3.9 1.4-4 4-.1 1-.1 1.3-.1 3.9s0 2.9.1 3.9c.1 2.6 1.4 3.9 4 4 1 .1 1.3.1 3.9.1s2.9 0 3.9-.1c2.6-.1 3.9-1.4 4-4 .1-1 .1-1.3.1-3.9s0-2.9-.1-3.9c-.1-2.6-1.4-3.9-4-4-1-.1-1.3-.1-3.9-.1zm0 1.7c2.5 0 2.8 0 3.8.1 1.8.1 2.7.9 2.8 2.8.1 1 .1 1.3.1 3.8s0 2.8-.1 3.8c-.1 1.8-.9 2.7-2.8 2.8-1 .1-1.3.1-3.8.1s-2.8 0-3.8-.1c-1.8-.1-2.7-.9-2.8-2.8-.1-1-.1-1.3-.1-3.8s0-2.8.1-3.8c.1-1.8 1-2.7 2.8-2.8 1-.1 1.3-.1 3.8-.1z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="site-social__link site-social__link--tiktok" href="<%= urls[:tiktok] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_tiktok") %>">
|
||||
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
|
||||
<path fill="#69C9D0" transform="translate(1.15 0.75)" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
|
||||
<path fill="#EE1D52" transform="translate(-1.15 -0.75)" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
|
||||
<path fill="#fff" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
|
||||
</svg>
|
||||
</a>
|
||||
<a class="site-social__link site-social__link--youtube" href="<%= urls[:youtube] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_youtube") %>">
|
||||
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
|
||||
<path fill="currentColor" d="M23.5 7.2a3 3 0 0 0-2.1-2.1C19.5 4.6 12 4.6 12 4.6s-7.5 0-9.4.5A3 3 0 0 0 .5 7.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-4.8zM9.6 15.5v-7l6.3 3.5-6.3 3.5z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</nav>
|
||||
@@ -196,6 +196,24 @@ module MatchLiveTv
|
||||
ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true"
|
||||
end
|
||||
|
||||
# Retention copia temporanea post-live YouTube (ore). Clamp 24–72, default 48.
|
||||
def youtube_temp_replay_retention_hours
|
||||
raw = ENV.fetch("YOUTUBE_TEMP_REPLAY_RETENTION_HOURS", "48").to_i
|
||||
raw.clamp(24, 72)
|
||||
end
|
||||
|
||||
def youtube_replay_verify_max_attempts
|
||||
ENV.fetch("YOUTUBE_REPLAY_VERIFY_MAX_ATTEMPTS", "12").to_i
|
||||
end
|
||||
|
||||
def youtube_replay_verify_base_interval_secs
|
||||
ENV.fetch("YOUTUBE_REPLAY_VERIFY_BASE_INTERVAL_SECS", "300").to_i
|
||||
end
|
||||
|
||||
def youtube_replay_verify_grace_secs
|
||||
ENV.fetch("YOUTUBE_REPLAY_VERIFY_GRACE_SECS", "120").to_i
|
||||
end
|
||||
|
||||
def ops_http_rails_url
|
||||
ENV.fetch("OPS_HTTP_RAILS_URL", "http://edge/up")
|
||||
end
|
||||
|
||||
@@ -10,6 +10,7 @@ de:
|
||||
billing: Abrechnung
|
||||
youtube: YouTube
|
||||
sessions: Sitzungen
|
||||
analytics: Analytics
|
||||
stream_nodes: Stream-Knoten
|
||||
password: Passwort
|
||||
logout: Abmelden
|
||||
@@ -110,6 +111,7 @@ de:
|
||||
table:
|
||||
match: Spiel
|
||||
status: Status
|
||||
client: Client
|
||||
ingest: Ingest
|
||||
start: Start
|
||||
link: Link
|
||||
@@ -251,29 +253,94 @@ de:
|
||||
sessions:
|
||||
index:
|
||||
title: Stream-Sitzungen
|
||||
lead: Suche nach Verein, Team, Gegner oder Ort. Filtere nach Status, Plattform, Knoten und Zeitraum.
|
||||
results: "%{shown} von %{total} angezeigt"
|
||||
none: Keine Sitzungen für diese Filter.
|
||||
filters:
|
||||
q: Suche
|
||||
q_placeholder: Verein, Team, Gegner…
|
||||
status: Status
|
||||
platform: Plattform
|
||||
club: Verein
|
||||
node: Ingest-Knoten
|
||||
from: Von
|
||||
to: Bis
|
||||
any: Alle
|
||||
apply: Filtern
|
||||
reset: Zurücksetzen
|
||||
table:
|
||||
club: Verein
|
||||
match: Spiel
|
||||
status: Status
|
||||
started: Start
|
||||
ended: Ende
|
||||
duration: Dauer
|
||||
ingest: Ingest
|
||||
disconnects: Verbindungsabbrüche
|
||||
client: Client
|
||||
link: Link
|
||||
detail: Details
|
||||
regia: Regie
|
||||
show:
|
||||
title: "Sitzung %{id}"
|
||||
heading: Sitzungsdetails
|
||||
back: "← Zurück zu Sitzungen"
|
||||
club_label: "Verein:"
|
||||
summary_title: Übersicht
|
||||
timing_title: Zeiten
|
||||
ingest_title: Ingest & Medien
|
||||
devices_title: Geräte
|
||||
events_title: Ereignisse
|
||||
events_none: Keine Ereignisse erfasst.
|
||||
status_label: "Status:"
|
||||
stop_button: Sitzung beenden
|
||||
stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet."
|
||||
match_label: "Spiel: %{team} gegen %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Übertragung
|
||||
open_replays: Vereins-Replays öffnen
|
||||
audio_muted: Audio stumm
|
||||
audio_on: Audio an
|
||||
ingest_node: "Ingest-Knoten:"
|
||||
rtmp_ingest: "RTMP-Ingest:"
|
||||
events_title: Ereignisse
|
||||
fields:
|
||||
id: ID
|
||||
club: Verein
|
||||
team: Team
|
||||
opponent: Gegner
|
||||
operator: Operator
|
||||
platform: Plattform
|
||||
client_os: System
|
||||
app_version: App-Version
|
||||
device: Gerät
|
||||
os_version: OS-Version
|
||||
carrier: Mobilfunkanbieter
|
||||
privacy: Privacy
|
||||
quality: Qualität
|
||||
min_quality: Min. Qualität
|
||||
audio: Audio
|
||||
created: Erstellt
|
||||
started: Start
|
||||
ended: Ende
|
||||
duration: Dauer
|
||||
disconnects: Abbrüche
|
||||
scheduled: Geplantes Spiel
|
||||
location: Ort
|
||||
node: Knoten
|
||||
rtmp: RTMP-Ingest
|
||||
hls: HLS
|
||||
recording: Replay
|
||||
devices:
|
||||
role: Rolle
|
||||
network: Netz
|
||||
battery: Akku
|
||||
bitrate: Bitrate
|
||||
fps: FPS
|
||||
last_seen: Zuletzt gesehen
|
||||
table:
|
||||
type: Typ
|
||||
when: Wann
|
||||
meta: Meta
|
||||
meta: Details
|
||||
links:
|
||||
watch_title: Live-Link
|
||||
none: Kein Video-Link für diese Sitzung verfügbar.
|
||||
@@ -286,11 +353,53 @@ de:
|
||||
generate_button: Regie-Link erzeugen
|
||||
ingest:
|
||||
none: "—"
|
||||
decommissioned: "(Knoten entfernt)"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
analytics:
|
||||
missing_path: Seite auswählen.
|
||||
filters:
|
||||
from: Von
|
||||
to: Bis
|
||||
device: Gerät
|
||||
any: Alle
|
||||
apply: Filtern
|
||||
reset: Zurücksetzen
|
||||
layer: Ebene
|
||||
layer_move: Mausbewegungen
|
||||
layer_click: Klicks
|
||||
index:
|
||||
title: Website-Analytics
|
||||
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
|
||||
none: Keine Daten im gewählten Zeitraum.
|
||||
heatmap: Heatmap
|
||||
table:
|
||||
path: Seite
|
||||
pageviews: Pageviews
|
||||
moves: Bewegungen
|
||||
clicks: Klicks
|
||||
avg_scroll: Scroll Ø
|
||||
max_scroll: Scroll max
|
||||
show:
|
||||
title: Seiten-Heatmap
|
||||
back: "← Zurück zu Analytics"
|
||||
summary: Übersicht
|
||||
scroll_depth: Scrolltiefe
|
||||
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
|
||||
heatmap_moves: Mausbewegungs-Karte
|
||||
heatmap_clicks: Klickkarte
|
||||
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel.
|
||||
preview_title: Seiten-Screenshot
|
||||
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
|
||||
snapshot_missing: Noch kein Screenshot. Seite mit Analytics-Einwilligung (Desktop) besuchen und erneut versuchen.
|
||||
snapshot_meta: "Screenshot %{device} · %{at}"
|
||||
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
|
||||
heatmap_title: Klickkarte
|
||||
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Zu berechnende Zahlungen
|
||||
|
||||
@@ -10,6 +10,7 @@ en:
|
||||
billing: Billing
|
||||
youtube: YouTube
|
||||
sessions: Sessions
|
||||
analytics: Analytics
|
||||
stream_nodes: Stream nodes
|
||||
password: Password
|
||||
logout: Log out
|
||||
@@ -110,6 +111,7 @@ en:
|
||||
table:
|
||||
match: Match
|
||||
status: Status
|
||||
client: Client
|
||||
ingest: Ingest
|
||||
start: Start
|
||||
link: Link
|
||||
@@ -251,29 +253,94 @@ en:
|
||||
sessions:
|
||||
index:
|
||||
title: Stream sessions
|
||||
lead: Search by club, team, opponent or venue. Filter by status, platform, node and date range.
|
||||
results: "Showing %{shown} of %{total}"
|
||||
none: No sessions match these filters.
|
||||
filters:
|
||||
q: Search
|
||||
q_placeholder: Club, team, opponent…
|
||||
status: Status
|
||||
platform: Platform
|
||||
club: Club
|
||||
node: Ingest node
|
||||
from: From
|
||||
to: To
|
||||
any: All
|
||||
apply: Filter
|
||||
reset: Reset
|
||||
table:
|
||||
club: Club
|
||||
match: Match
|
||||
status: Status
|
||||
started: Started
|
||||
ended: Ended
|
||||
duration: Duration
|
||||
ingest: Ingest
|
||||
disconnects: Disconnects
|
||||
client: Client
|
||||
link: Link
|
||||
detail: Details
|
||||
regia: Control
|
||||
show:
|
||||
title: "Session %{id}"
|
||||
heading: Session details
|
||||
back: "← Back to sessions"
|
||||
club_label: "Club:"
|
||||
summary_title: Summary
|
||||
timing_title: Timing
|
||||
ingest_title: Ingest & media
|
||||
devices_title: Devices
|
||||
events_title: Events
|
||||
events_none: No events recorded.
|
||||
status_label: "Status:"
|
||||
stop_button: End session
|
||||
stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended."
|
||||
match_label: "Match: %{team} vs %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Broadcast
|
||||
open_replays: Open club replays
|
||||
audio_muted: Audio muted
|
||||
audio_on: Audio on
|
||||
ingest_node: "Ingest node:"
|
||||
rtmp_ingest: "RTMP ingest:"
|
||||
events_title: Events
|
||||
fields:
|
||||
id: ID
|
||||
club: Club
|
||||
team: Team
|
||||
opponent: Opponent
|
||||
operator: Operator
|
||||
platform: Platform
|
||||
client_os: OS
|
||||
app_version: App version
|
||||
device: Device
|
||||
os_version: OS version
|
||||
carrier: Carrier
|
||||
privacy: Privacy
|
||||
quality: Quality
|
||||
min_quality: Min quality
|
||||
audio: Audio
|
||||
created: Created
|
||||
started: Started
|
||||
ended: Ended
|
||||
duration: Duration
|
||||
disconnects: Disconnects
|
||||
scheduled: Scheduled match
|
||||
location: Venue
|
||||
node: Node
|
||||
rtmp: RTMP ingest
|
||||
hls: HLS
|
||||
recording: Replay
|
||||
devices:
|
||||
role: Role
|
||||
network: Network
|
||||
battery: Battery
|
||||
bitrate: Bitrate
|
||||
fps: FPS
|
||||
last_seen: Last seen
|
||||
table:
|
||||
type: Type
|
||||
when: When
|
||||
meta: Meta
|
||||
meta: Details
|
||||
links:
|
||||
watch_title: Live link
|
||||
none: No video link available for this session.
|
||||
@@ -286,11 +353,53 @@ en:
|
||||
generate_button: Generate control link
|
||||
ingest:
|
||||
none: "—"
|
||||
decommissioned: "(node removed)"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
analytics:
|
||||
missing_path: Select a page.
|
||||
filters:
|
||||
from: From
|
||||
to: To
|
||||
device: Device
|
||||
any: All
|
||||
apply: Filter
|
||||
reset: Reset
|
||||
layer: Layer
|
||||
layer_move: Mouse moves
|
||||
layer_click: Clicks
|
||||
index:
|
||||
title: Site analytics
|
||||
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
|
||||
none: No data in the selected period.
|
||||
heatmap: Heatmap
|
||||
table:
|
||||
path: Page
|
||||
pageviews: Pageviews
|
||||
moves: Moves
|
||||
clicks: Clicks
|
||||
avg_scroll: Avg scroll
|
||||
max_scroll: Max scroll
|
||||
show:
|
||||
title: Page heatmap
|
||||
back: "← Back to Analytics"
|
||||
summary: Summary
|
||||
scroll_depth: Scroll depth
|
||||
scroll_hint: "Average %{avg}% · max %{max}%"
|
||||
heatmap_moves: Mouse-move map
|
||||
heatmap_clicks: Click map
|
||||
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high.
|
||||
preview_title: Page screenshot
|
||||
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
|
||||
snapshot_missing: No screenshot yet. Visit the page on the site with analytics consent (desktop), then retry in a few seconds.
|
||||
snapshot_meta: "Screenshot %{device} · captured %{at}"
|
||||
no_points: No aggregated points for this layer in the period.
|
||||
heatmap_title: Click map
|
||||
no_clicks: No aggregated clicks for this page in the period.
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Payments to invoice
|
||||
|
||||
@@ -10,6 +10,7 @@ es:
|
||||
billing: Facturación
|
||||
youtube: YouTube
|
||||
sessions: Sesiones
|
||||
analytics: Analytics
|
||||
stream_nodes: Nodos stream
|
||||
password: Contraseña
|
||||
logout: Salir
|
||||
@@ -110,6 +111,7 @@ es:
|
||||
table:
|
||||
match: Partido
|
||||
status: Estado
|
||||
client: Cliente
|
||||
ingest: Ingest
|
||||
start: Inicio
|
||||
link: Enlace
|
||||
@@ -251,29 +253,94 @@ es:
|
||||
sessions:
|
||||
index:
|
||||
title: Sesiones de emisión
|
||||
lead: Busca por club, equipo, rival o sede. Filtra por estado, plataforma, nodo y periodo.
|
||||
results: "Mostrando %{shown} de %{total}"
|
||||
none: Ninguna sesión coincide con estos filtros.
|
||||
filters:
|
||||
q: Buscar
|
||||
q_placeholder: Club, equipo, rival…
|
||||
status: Estado
|
||||
platform: Plataforma
|
||||
club: Club
|
||||
node: Nodo ingest
|
||||
from: Desde
|
||||
to: Hasta
|
||||
any: Todos
|
||||
apply: Filtrar
|
||||
reset: Restablecer
|
||||
table:
|
||||
club: Club
|
||||
match: Partido
|
||||
status: Estado
|
||||
started: Inicio
|
||||
ended: Fin
|
||||
duration: Duración
|
||||
ingest: Ingest
|
||||
disconnects: Desconexiones
|
||||
client: Cliente
|
||||
link: Enlace
|
||||
detail: Detalle
|
||||
regia: Regie
|
||||
show:
|
||||
title: "Sesión %{id}"
|
||||
heading: Detalle de sesión
|
||||
back: "← Volver a sesiones"
|
||||
club_label: "Club:"
|
||||
summary_title: Resumen
|
||||
timing_title: Tiempos
|
||||
ingest_title: Ingest y medios
|
||||
devices_title: Dispositivos
|
||||
events_title: Eventos
|
||||
events_none: No hay eventos registrados.
|
||||
status_label: "Estado:"
|
||||
stop_button: Finalizar sesión
|
||||
stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada."
|
||||
match_label: "Partido: %{team} vs %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Emisión
|
||||
open_replays: Abrir replays del club
|
||||
audio_muted: Audio silenciado
|
||||
audio_on: Audio activo
|
||||
ingest_node: "Nodo ingest:"
|
||||
rtmp_ingest: "Ingesta RTMP:"
|
||||
events_title: Eventos
|
||||
fields:
|
||||
id: ID
|
||||
club: Club
|
||||
team: Equipo
|
||||
opponent: Rival
|
||||
operator: Operador
|
||||
platform: Plataforma
|
||||
client_os: Sistema
|
||||
app_version: Versión app
|
||||
device: Dispositivo
|
||||
os_version: Versión OS
|
||||
carrier: Operador móvil
|
||||
privacy: Privacidad
|
||||
quality: Calidad
|
||||
min_quality: Calidad mínima
|
||||
audio: Audio
|
||||
created: Creada
|
||||
started: Inicio
|
||||
ended: Fin
|
||||
duration: Duración
|
||||
disconnects: Desconexiones
|
||||
scheduled: Partido programado
|
||||
location: Sede
|
||||
node: Nodo
|
||||
rtmp: Ingesta RTMP
|
||||
hls: HLS
|
||||
recording: Replay
|
||||
devices:
|
||||
role: Rol
|
||||
network: Red
|
||||
battery: Batería
|
||||
bitrate: Bitrate
|
||||
fps: FPS
|
||||
last_seen: Último contacto
|
||||
table:
|
||||
type: Tipo
|
||||
when: Cuándo
|
||||
meta: Meta
|
||||
meta: Detalles
|
||||
links:
|
||||
watch_title: Enlace en directo
|
||||
none: No hay ningún enlace de vídeo disponible para esta sesión.
|
||||
@@ -286,11 +353,53 @@ es:
|
||||
generate_button: Generar enlace de regie
|
||||
ingest:
|
||||
none: "—"
|
||||
decommissioned: "(nodo eliminado)"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
analytics:
|
||||
missing_path: Selecciona una página.
|
||||
filters:
|
||||
from: Desde
|
||||
to: Hasta
|
||||
device: Dispositivo
|
||||
any: Todos
|
||||
apply: Filtrar
|
||||
reset: Restablecer
|
||||
layer: Capa
|
||||
layer_move: Movimientos del ratón
|
||||
layer_click: Clics
|
||||
index:
|
||||
title: Analytics del sitio
|
||||
lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
|
||||
none: No hay datos en el periodo seleccionado.
|
||||
heatmap: Heatmap
|
||||
table:
|
||||
path: Página
|
||||
pageviews: Pageviews
|
||||
moves: Movimientos
|
||||
clicks: Clics
|
||||
avg_scroll: Scroll medio
|
||||
max_scroll: Scroll máx
|
||||
show:
|
||||
title: Heatmap de página
|
||||
back: "← Volver a Analytics"
|
||||
summary: Resumen
|
||||
scroll_depth: Profundidad de scroll
|
||||
scroll_hint: "Media %{avg}% · máximo %{max}%"
|
||||
heatmap_moves: Mapa de movimientos
|
||||
heatmap_clicks: Mapa de clics
|
||||
heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho.
|
||||
preview_title: Captura de página
|
||||
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
|
||||
snapshot_missing: Aún no hay captura. Visita la página con consentimiento analytics (escritorio) y vuelve a intentarlo.
|
||||
snapshot_meta: "Captura %{device} · %{at}"
|
||||
no_points: No hay puntos agregados para esta capa en el periodo.
|
||||
heatmap_title: Mapa de clics
|
||||
no_clicks: No hay clics agregados para esta página en el periodo.
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Pagos por facturar
|
||||
|
||||
@@ -10,6 +10,7 @@ fr:
|
||||
billing: Facturation
|
||||
youtube: YouTube
|
||||
sessions: Sessions
|
||||
analytics: Analytics
|
||||
stream_nodes: Nœuds stream
|
||||
password: Mot de passe
|
||||
logout: Déconnexion
|
||||
@@ -110,6 +111,7 @@ fr:
|
||||
table:
|
||||
match: Match
|
||||
status: Statut
|
||||
client: Client
|
||||
ingest: Ingest
|
||||
start: Début
|
||||
link: Lien
|
||||
@@ -251,29 +253,94 @@ fr:
|
||||
sessions:
|
||||
index:
|
||||
title: Sessions de diffusion
|
||||
lead: Recherchez par club, équipe, adversaire ou lieu. Filtrez par statut, plateforme, nœud et période.
|
||||
results: "%{shown} sur %{total} affichées"
|
||||
none: Aucune session ne correspond à ces filtres.
|
||||
filters:
|
||||
q: Recherche
|
||||
q_placeholder: Club, équipe, adversaire…
|
||||
status: Statut
|
||||
platform: Plateforme
|
||||
club: Club
|
||||
node: Nœud ingest
|
||||
from: Du
|
||||
to: Au
|
||||
any: Tous
|
||||
apply: Filtrer
|
||||
reset: Réinitialiser
|
||||
table:
|
||||
club: Club
|
||||
match: Match
|
||||
status: Statut
|
||||
started: Début
|
||||
ended: Fin
|
||||
duration: Durée
|
||||
ingest: Ingest
|
||||
disconnects: Déconnexions
|
||||
client: Client
|
||||
link: Lien
|
||||
detail: Détail
|
||||
regia: Régie
|
||||
show:
|
||||
title: "Session %{id}"
|
||||
heading: Détail de la session
|
||||
back: "← Retour aux sessions"
|
||||
club_label: "Club :"
|
||||
summary_title: Résumé
|
||||
timing_title: Horaires
|
||||
ingest_title: Ingest et médias
|
||||
devices_title: Appareils
|
||||
events_title: Événements
|
||||
events_none: Aucun événement enregistré.
|
||||
status_label: "Statut :"
|
||||
stop_button: Terminer la session
|
||||
stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé."
|
||||
match_label: "Match : %{team} contre %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Diffusion
|
||||
open_replays: Ouvrir les replays du club
|
||||
audio_muted: Audio coupé
|
||||
audio_on: Audio actif
|
||||
ingest_node: "Nœud ingest :"
|
||||
rtmp_ingest: "Ingestion RTMP :"
|
||||
events_title: Événements
|
||||
fields:
|
||||
id: ID
|
||||
club: Club
|
||||
team: Équipe
|
||||
opponent: Adversaire
|
||||
operator: Opérateur
|
||||
platform: Plateforme
|
||||
client_os: Système
|
||||
app_version: Version app
|
||||
device: Appareil
|
||||
os_version: Version OS
|
||||
carrier: Opérateur mobile
|
||||
privacy: Confidentialité
|
||||
quality: Qualité
|
||||
min_quality: Qualité mini
|
||||
audio: Audio
|
||||
created: Créée
|
||||
started: Début
|
||||
ended: Fin
|
||||
duration: Durée
|
||||
disconnects: Déconnexions
|
||||
scheduled: Match programmé
|
||||
location: Lieu
|
||||
node: Nœud
|
||||
rtmp: Ingestion RTMP
|
||||
hls: HLS
|
||||
recording: Replay
|
||||
devices:
|
||||
role: Rôle
|
||||
network: Réseau
|
||||
battery: Batterie
|
||||
bitrate: Débit
|
||||
fps: FPS
|
||||
last_seen: Dernier contact
|
||||
table:
|
||||
type: Type
|
||||
when: Quand
|
||||
meta: Méta
|
||||
meta: Détails
|
||||
links:
|
||||
watch_title: Lien en direct
|
||||
none: Aucun lien vidéo disponible pour cette session.
|
||||
@@ -286,11 +353,53 @@ fr:
|
||||
generate_button: Générer le lien de régie
|
||||
ingest:
|
||||
none: "—"
|
||||
decommissioned: "(nœud retiré)"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
analytics:
|
||||
missing_path: Sélectionnez une page.
|
||||
filters:
|
||||
from: Du
|
||||
to: Au
|
||||
device: Appareil
|
||||
any: Tous
|
||||
apply: Filtrer
|
||||
reset: Réinitialiser
|
||||
layer: Couche
|
||||
layer_move: Mouvements souris
|
||||
layer_click: Clics
|
||||
index:
|
||||
title: Analytics du site
|
||||
lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
|
||||
none: Aucune donnée sur la période sélectionnée.
|
||||
heatmap: Heatmap
|
||||
table:
|
||||
path: Page
|
||||
pageviews: Pages vues
|
||||
moves: Mouvements
|
||||
clicks: Clics
|
||||
avg_scroll: Scroll moyen
|
||||
max_scroll: Scroll max
|
||||
show:
|
||||
title: Heatmap de page
|
||||
back: "← Retour aux Analytics"
|
||||
summary: Résumé
|
||||
scroll_depth: Profondeur de scroll
|
||||
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
|
||||
heatmap_moves: Carte des mouvements
|
||||
heatmap_clicks: Carte des clics
|
||||
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort.
|
||||
preview_title: Capture de page
|
||||
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
|
||||
snapshot_missing: Pas encore de capture. Visitez la page avec consentement analytics (desktop), puis réessayez.
|
||||
snapshot_meta: "Capture %{device} · %{at}"
|
||||
no_points: Aucun point agrégé pour cette couche sur la période.
|
||||
heatmap_title: Carte des clics
|
||||
no_clicks: Aucun clic agrégé pour cette page sur la période.
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Paiements à facturer
|
||||
|
||||
@@ -10,6 +10,7 @@ it:
|
||||
billing: Fatturazione
|
||||
youtube: YouTube
|
||||
sessions: Sessioni
|
||||
analytics: Analytics
|
||||
stream_nodes: Nodi stream
|
||||
password: Password
|
||||
logout: Esci
|
||||
@@ -114,6 +115,7 @@ it:
|
||||
table:
|
||||
match: Partita
|
||||
status: Stato
|
||||
client: Client
|
||||
ingest: Ingest
|
||||
start: Inizio
|
||||
link: Link
|
||||
@@ -272,29 +274,94 @@ it:
|
||||
sessions:
|
||||
index:
|
||||
title: Sessioni di streaming
|
||||
lead: Cerca per società, squadra, avversario o sede. Filtra per stato, piattaforma, nodo e periodo.
|
||||
results: "Mostrate %{shown} di %{total}"
|
||||
none: Nessuna sessione trovata con questi filtri.
|
||||
filters:
|
||||
q: Cerca
|
||||
q_placeholder: Società, squadra, avversario…
|
||||
status: Stato
|
||||
platform: Piattaforma
|
||||
club: Società
|
||||
node: Nodo ingest
|
||||
from: Da
|
||||
to: A
|
||||
any: Tutti
|
||||
apply: Filtra
|
||||
reset: Azzera
|
||||
table:
|
||||
club: Società
|
||||
match: Match
|
||||
status: Stato
|
||||
started: Inizio
|
||||
ended: Fine
|
||||
duration: Durata
|
||||
ingest: Ingest
|
||||
disconnects: Disconnessioni
|
||||
client: Client
|
||||
link: Link
|
||||
detail: Dettaglio
|
||||
regia: Regia
|
||||
show:
|
||||
title: "Sessione %{id}"
|
||||
heading: Dettaglio sessione
|
||||
back: "← Torna alle sessioni"
|
||||
club_label: "Società:"
|
||||
summary_title: Riepilogo
|
||||
timing_title: Tempi
|
||||
ingest_title: Ingest e media
|
||||
devices_title: Dispositivi
|
||||
events_title: Eventi
|
||||
events_none: Nessun evento registrato.
|
||||
status_label: "Stato:"
|
||||
stop_button: Termina sessione
|
||||
stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended."
|
||||
match_label: "Match: %{team} vs %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Broadcast
|
||||
open_replays: Apri replay società
|
||||
audio_muted: Audio muto
|
||||
audio_on: Audio attivo
|
||||
ingest_node: "Nodo ingest:"
|
||||
rtmp_ingest: "RTMP ingest:"
|
||||
events_title: Eventi
|
||||
fields:
|
||||
id: ID
|
||||
club: Società
|
||||
team: Squadra
|
||||
opponent: Avversario
|
||||
operator: Operatore
|
||||
platform: Piattaforma
|
||||
client_os: Sistema
|
||||
app_version: Versione app
|
||||
device: Dispositivo
|
||||
os_version: Versione OS
|
||||
carrier: Operatore telefonico
|
||||
privacy: Privacy
|
||||
quality: Qualità
|
||||
min_quality: Qualità minima
|
||||
audio: Audio
|
||||
created: Creata
|
||||
started: Inizio
|
||||
ended: Fine
|
||||
duration: Durata
|
||||
disconnects: Disconnessioni
|
||||
scheduled: Partita programmata
|
||||
location: Sede
|
||||
node: Nodo
|
||||
rtmp: RTMP ingest
|
||||
hls: HLS
|
||||
recording: Replay
|
||||
devices:
|
||||
role: Ruolo
|
||||
network: Rete
|
||||
battery: Batteria
|
||||
bitrate: Bitrate
|
||||
fps: FPS
|
||||
last_seen: Ultimo contatto
|
||||
table:
|
||||
type: Tipo
|
||||
when: Quando
|
||||
meta: Meta
|
||||
meta: Dettagli
|
||||
links:
|
||||
watch_title: Link diretta
|
||||
none: Nessun link video disponibile per questa sessione.
|
||||
@@ -307,11 +374,53 @@ it:
|
||||
generate_button: Genera link regia
|
||||
ingest:
|
||||
none: "—"
|
||||
decommissioned: "(nodo rimosso)"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
analytics:
|
||||
missing_path: Seleziona una pagina.
|
||||
filters:
|
||||
from: Da
|
||||
to: A
|
||||
device: Device
|
||||
any: Tutti
|
||||
apply: Filtra
|
||||
reset: Azzera
|
||||
layer: Livello
|
||||
layer_move: Movimenti mouse
|
||||
layer_click: Click
|
||||
index:
|
||||
title: Analytics sito
|
||||
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
|
||||
none: Nessun dato nel periodo selezionato.
|
||||
heatmap: Heatmap
|
||||
table:
|
||||
path: Pagina
|
||||
pageviews: Pageview
|
||||
moves: Movimenti
|
||||
clicks: Click
|
||||
avg_scroll: Scroll medio
|
||||
max_scroll: Scroll max
|
||||
show:
|
||||
title: Heatmap pagina
|
||||
back: "← Torna ad Analytics"
|
||||
summary: Riepilogo
|
||||
scroll_depth: Profondità di scroll
|
||||
scroll_hint: "Media %{avg}% · massimo %{max}%"
|
||||
heatmap_moves: Mappa movimenti mouse
|
||||
heatmap_clicks: Mappa click
|
||||
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto.
|
||||
preview_title: Screenshot pagina
|
||||
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
|
||||
snapshot_missing: Nessuno screenshot ancora. Visita la pagina sul sito con consenso analytics (desktop) e riprova tra qualche secondo.
|
||||
snapshot_meta: "Screenshot %{device} · catturato %{at}"
|
||||
no_points: Nessun punto aggregato per questo livello nel periodo.
|
||||
heatmap_title: Mappa click
|
||||
no_clicks: Nessun click aggregato per questa pagina nel periodo.
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Pagamenti da fatturare
|
||||
|
||||
@@ -88,7 +88,7 @@ de:
|
||||
s10_title: 10. Sicherheit
|
||||
s10_body: "Wir setzen angemessene technische und organisatorische Maßnahmen ein (authentifizierter Zugang, verschlüsselte Passwörter, HTTPS-Kommunikation, Trennung der Umgebungen, eingeschränkter Personalzugang). Kein System ist unverwundbar: Wenn Sie einen unbefugten Zugriff vermuten, ändern Sie Ihr Passwort und kontaktieren Sie uns."
|
||||
s11_title: 11. Cookies und ähnliche Technologien
|
||||
s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, <strong>Google Analytics</strong> für aggregierte Statistiken."
|
||||
s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, <strong>Google Analytics</strong> und <strong>aggregierte First-Party-Statistiken</strong> (Mausbewegungs-/Klick-/Scroll-Heatmaps) zur Produktverbesserung."
|
||||
s11_p2_html: "Sie können Ihre Präferenzen jederzeit über den Link „Cookies verwalten“ in der Fußzeile ändern oder die %{cookie_policy_link} einsehen."
|
||||
s11_cookie_policy_link_text: Cookie-Richtlinie
|
||||
s12_title: 12. Änderungen
|
||||
@@ -169,7 +169,7 @@ de:
|
||||
s4_1_row2_duration: 12 Monate
|
||||
s4_1_row2_provider: Match Live TV (First-Party)
|
||||
s4_2_title: 4.2 Analytics (nur mit Ihrer Einwilligung)
|
||||
s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir <strong>Google Analytics 4</strong>, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet."
|
||||
s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir <strong>Google Analytics 4</strong>, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet. Mit derselben Einwilligung erfassen wir außerdem <strong>aggregierte First-Party-Statistiken</strong> (Mausbewegungen, Klicks und Scrolltiefe, ohne Nutzeridentifikation) für interne Heatmaps zur Produktverbesserung und Plattform-Marketing. Wir tracken weder Admin-Bereich, Regie, Live-Player noch Replay-Seiten."
|
||||
s4_2_active_html: "Aktive Mess-ID auf der Website: <code>%{id}</code>"
|
||||
s4_2_inactive: Google Analytics wird nur konfiguriert, wenn der Verantwortliche die Mess-ID auf dem Server einrichtet.
|
||||
table2_col_name: Name
|
||||
@@ -185,6 +185,10 @@ de:
|
||||
s4_2_row3_purpose: Unterscheidet Nutzer (Statistiken)
|
||||
s4_2_row3_duration: 24 Stunden
|
||||
s4_2_row3_provider: Google
|
||||
s4_2_row4_name_html: "Heatmap / Scroll (First-Party)"
|
||||
s4_2_row4_purpose: Aggregierte Mausbewegungs-, Klick- und Scrolltiefe-Zählungen nach Seite und Gerätetyp (keine Nutzerkennungen)
|
||||
s4_2_row4_duration: Aggregate bis zu 30 Tage
|
||||
s4_2_row4_provider: Match Live TV (First-Party)
|
||||
s4_2_p2_html: "Sie können die Einwilligung über das Banner oder die Browsereinstellungen widerrufen. Google-Informationen: %{google_privacy_link}, %{google_optout_link}."
|
||||
s4_2_google_privacy_link_text: Google-Datenschutzerklärung
|
||||
s4_2_google_optout_link_text: Analytics-Deaktivierungs-Add-on
|
||||
|
||||
@@ -88,7 +88,7 @@ en:
|
||||
s10_title: 10. Security
|
||||
s10_body: "We adopt appropriate technical and organisational measures (authenticated access, encrypted passwords, HTTPS communications, environment segregation, restricted staff access). No system is invulnerable: if you suspect unauthorised access, change your password and contact us."
|
||||
s11_title: 11. Cookies and similar technologies
|
||||
s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, <strong>Google Analytics</strong> for aggregated statistics."
|
||||
s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, <strong>Google Analytics</strong> and <strong>first-party aggregated statistics</strong> (mouse-move/click/scroll heatmaps) to improve the product."
|
||||
s11_p2_html: "You can manage your preferences at any time via the “Manage cookies” link in the footer or consult the %{cookie_policy_link}."
|
||||
s11_cookie_policy_link_text: Cookie policy
|
||||
s12_title: 12. Changes
|
||||
@@ -169,7 +169,7 @@ en:
|
||||
s4_1_row2_duration: 12 months
|
||||
s4_1_row2_provider: Match Live TV (first-party)
|
||||
s4_2_title: 4.2 Analytics (only with your consent)
|
||||
s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load <strong>Google Analytics 4</strong> to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies."
|
||||
s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load <strong>Google Analytics 4</strong> to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies. With the same consent we also collect <strong>first-party aggregated statistics</strong> (mouse movements, clicks and scroll depth, without identifying the user) for internal heatmaps used to improve the product and platform marketing. We do not track the admin area, control room, live player or replay pages."
|
||||
s4_2_active_html: "Measurement ID active on the site: <code>%{id}</code>"
|
||||
s4_2_inactive: Google Analytics is only configured when the controller sets the measurement ID on the server.
|
||||
table2_col_name: Name
|
||||
@@ -185,6 +185,10 @@ en:
|
||||
s4_2_row3_purpose: Distinguishes users (statistics)
|
||||
s4_2_row3_duration: 24 hours
|
||||
s4_2_row3_provider: Google
|
||||
s4_2_row4_name_html: "Heatmap / scroll (first-party)"
|
||||
s4_2_row4_purpose: Aggregated mouse-move, click and scroll-depth counts, plus page screenshots for heatmaps (no user identifiers)
|
||||
s4_2_row4_duration: Aggregates kept up to 30 days
|
||||
s4_2_row4_provider: Match Live TV (first-party)
|
||||
s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}."
|
||||
s4_2_google_privacy_link_text: Google Privacy Policy
|
||||
s4_2_google_optout_link_text: Analytics opt-out add-on
|
||||
|
||||
@@ -88,7 +88,7 @@ es:
|
||||
s10_title: 10. Seguridad
|
||||
s10_body: "Adoptamos medidas técnicas y organizativas adecuadas (acceso autenticado, contraseñas cifradas, comunicaciones HTTPS, segregación de entornos, acceso restringido del personal). Ningún sistema es invulnerable: si sospechas un acceso no autorizado, cambia la contraseña y contáctanos."
|
||||
s11_title: 11. Cookies y tecnologías similares
|
||||
s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, <strong>Google Analytics</strong> para estadísticas agregadas."
|
||||
s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, <strong>Google Analytics</strong> y <strong>estadísticas agregadas de primera parte</strong> (heatmaps de movimientos del ratón/clics/scroll) para mejorar el producto."
|
||||
s11_p2_html: "Puedes gestionar tus preferencias en cualquier momento desde el enlace «Gestionar cookies» en el pie de página o consultar la %{cookie_policy_link}."
|
||||
s11_cookie_policy_link_text: política de cookies
|
||||
s12_title: 12. Modificaciones
|
||||
@@ -169,7 +169,7 @@ es:
|
||||
s4_1_row2_duration: 12 meses
|
||||
s4_1_row2_provider: Match Live TV (propia)
|
||||
s4_2_title: 4.2 Analíticas (solo con tu consentimiento)
|
||||
s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos <strong>Google Analytics 4</strong> para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas."
|
||||
s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos <strong>Google Analytics 4</strong> para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas. Con el mismo consentimiento también recopilamos <strong>estadísticas agregadas de primera parte</strong> (movimientos del ratón, clics y profundidad de scroll, sin identificar al usuario) para heatmaps internas de mejora del producto y marketing de la plataforma. No rastreamos el área admin, la regia, el player en directo ni las páginas de replay."
|
||||
s4_2_active_html: "ID de medición activo en el sitio: <code>%{id}</code>"
|
||||
s4_2_inactive: Google Analytics solo se configura cuando el responsable establece el ID de medición en el servidor.
|
||||
table2_col_name: Nombre
|
||||
@@ -185,6 +185,10 @@ es:
|
||||
s4_2_row3_purpose: Distingue a los usuarios (estadísticas)
|
||||
s4_2_row3_duration: 24 horas
|
||||
s4_2_row3_provider: Google
|
||||
s4_2_row4_name_html: "Heatmap / scroll (propia)"
|
||||
s4_2_row4_purpose: Conteos agregados de movimientos del ratón, clics y profundidad de scroll por página y tipo de dispositivo (sin identificadores de usuario)
|
||||
s4_2_row4_duration: Agregados hasta 30 días
|
||||
s4_2_row4_provider: Match Live TV (propia)
|
||||
s4_2_p2_html: "Puedes revocar el consentimiento desde el banner o desde la configuración del navegador. Información de Google: %{google_privacy_link}, %{google_optout_link}."
|
||||
s4_2_google_privacy_link_text: Política de privacidad de Google
|
||||
s4_2_google_optout_link_text: complemento de exclusión de Analytics
|
||||
|
||||
@@ -88,7 +88,7 @@ fr:
|
||||
s10_title: 10. Sécurité
|
||||
s10_body: "Nous adoptons des mesures techniques et organisationnelles appropriées (accès authentifié, mots de passe chiffrés, communications HTTPS, séparation des environnements, accès du staff limité). Aucun système n'est invulnérable : si vous suspectez un accès non autorisé, changez votre mot de passe et contactez-nous."
|
||||
s11_title: 11. Cookies et technologies similaires
|
||||
s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, <strong>Google Analytics</strong> pour des statistiques agrégées."
|
||||
s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, <strong>Google Analytics</strong> et des <strong>statistiques agrégées first-party</strong> (heatmaps mouvements souris/clics/scroll) pour améliorer le produit."
|
||||
s11_p2_html: "Vous pouvez gérer vos préférences à tout moment via le lien « Gérer les cookies » dans le pied de page ou consulter la %{cookie_policy_link}."
|
||||
s11_cookie_policy_link_text: politique de cookies
|
||||
s12_title: 12. Modifications
|
||||
@@ -169,7 +169,7 @@ fr:
|
||||
s4_1_row2_duration: 12 mois
|
||||
s4_1_row2_provider: Match Live TV (première partie)
|
||||
s4_2_title: 4.2 Analytics (uniquement avec votre consentement)
|
||||
s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons <strong>Google Analytics 4</strong> pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques."
|
||||
s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons <strong>Google Analytics 4</strong> pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques. Avec le même consentement, nous collectons aussi des <strong>statistiques agrégées first-party</strong> (mouvements de souris, clics et profondeur de scroll, sans identifier l'utilisateur) pour des heatmaps internes d'amélioration produit et marketing plateforme. Nous ne suivons pas l'admin, la régie, le player live ni les pages replay."
|
||||
s4_2_active_html: "ID de mesure actif sur le site : <code>%{id}</code>"
|
||||
s4_2_inactive: Google Analytics n'est configuré que lorsque le responsable définit l'ID de mesure sur le serveur.
|
||||
table2_col_name: Nom
|
||||
@@ -185,6 +185,10 @@ fr:
|
||||
s4_2_row3_purpose: Distingue les utilisateurs (statistiques)
|
||||
s4_2_row3_duration: 24 heures
|
||||
s4_2_row3_provider: Google
|
||||
s4_2_row4_name_html: "Heatmap / scroll (première partie)"
|
||||
s4_2_row4_purpose: Compteurs agrégés de mouvements souris, clics et profondeur de scroll par page et type d'appareil (sans identifiant utilisateur)
|
||||
s4_2_row4_duration: Agrégats conservés jusqu'à 30 jours
|
||||
s4_2_row4_provider: Match Live TV (première partie)
|
||||
s4_2_p2_html: "Vous pouvez retirer votre consentement depuis la bannière ou depuis les paramètres du navigateur. Informations Google : %{google_privacy_link}, %{google_optout_link}."
|
||||
s4_2_google_privacy_link_text: Politique de confidentialité Google
|
||||
s4_2_google_optout_link_text: module de désactivation Analytics
|
||||
|
||||
@@ -88,7 +88,7 @@ it:
|
||||
s10_title: 10. Sicurezza
|
||||
s10_body: "Adottiamo misure tecniche e organizzative adeguate (accesso autenticato, password crittografate, comunicazioni HTTPS, segregazione ambienti, limitazione accessi staff). Nessun sistema è invulnerabile: se sospetti un accesso non autorizzato, cambia password e contattaci."
|
||||
s11_title: 11. Cookie e tecnologie simili
|
||||
s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, <strong>Google Analytics</strong> per statistiche aggregate."
|
||||
s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, <strong>Google Analytics</strong> e <strong>statistiche aggregate di prima parte</strong> (heatmap movimenti mouse/click/scroll) per migliorare il prodotto."
|
||||
s11_p2_html: "Puoi gestire le preferenze in qualsiasi momento dal link «Gestisci cookie» nel footer o consultare la %{cookie_policy_link}."
|
||||
s11_cookie_policy_link_text: Cookie policy
|
||||
s12_title: 12. Modifiche
|
||||
@@ -169,7 +169,7 @@ it:
|
||||
s4_1_row2_duration: 12 mesi
|
||||
s4_1_row2_provider: Match Live TV (prima parte)
|
||||
s4_2_title: 4.2 Analytics (solo con il tuo consenso)
|
||||
s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo <strong>Google Analytics 4</strong> per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy."
|
||||
s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo <strong>Google Analytics 4</strong> per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy. In parallelo, con lo stesso consenso, raccogliamo <strong>statistiche aggregate di prima parte</strong> (movimenti del mouse, click e profondità di scroll, senza identificare l’utente) per heatmaps interne usate dal titolare a fini di miglioramento del prodotto e marketing della piattaforma. Non tracciamo area admin, regia, player live né replay."
|
||||
s4_2_active_html: "ID misurazione attivo sul sito: <code>%{id}</code>"
|
||||
s4_2_inactive: Google Analytics è configurato solo quando il titolare imposta l’ID misurazione sul server.
|
||||
table2_col_name: Nome
|
||||
@@ -185,6 +185,10 @@ it:
|
||||
s4_2_row3_purpose: Distingue gli utenti (statistiche)
|
||||
s4_2_row3_duration: 24 ore
|
||||
s4_2_row3_provider: Google
|
||||
s4_2_row4_name_html: "Heatmap / scroll (prima parte)"
|
||||
s4_2_row4_purpose: Conteggi aggregati di movimenti mouse, click e profondità di scroll, più screenshot della pagina per le heatmaps (nessun identificativo utente)
|
||||
s4_2_row4_duration: Aggregati fino a 30 giorni
|
||||
s4_2_row4_provider: Match Live TV (prima parte)
|
||||
s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}."
|
||||
s4_2_google_privacy_link_text: Privacy Policy Google
|
||||
s4_2_google_optout_link_text: componente opt-out Analytics
|
||||
|
||||
@@ -192,6 +192,7 @@ de:
|
||||
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
|
||||
company_title: Sitz
|
||||
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
|
||||
social_title: Folgen Sie uns auch in den sozialen Medien
|
||||
label_address: Adresse
|
||||
label_vat: USt-IdNr.
|
||||
form_title: Nachricht senden
|
||||
|
||||
@@ -192,6 +192,7 @@ en:
|
||||
privacy_body: GDPR rights, consent and personal data. Write to
|
||||
company_title: Company details
|
||||
company_lead: Service provider and data controller.
|
||||
social_title: Follow us on social media
|
||||
label_address: Address
|
||||
label_vat: VAT number
|
||||
form_title: Send a message
|
||||
|
||||
@@ -192,6 +192,7 @@ es:
|
||||
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
|
||||
company_title: Sede
|
||||
company_lead: Prestador del servicio y responsable del tratamiento.
|
||||
social_title: Síguenos también en redes sociales
|
||||
label_address: Dirección
|
||||
label_vat: NIF / IVA
|
||||
form_title: Enviar un mensaje
|
||||
|
||||
@@ -192,6 +192,7 @@ fr:
|
||||
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
|
||||
company_title: Siège
|
||||
company_lead: Prestataire du service et responsable du traitement.
|
||||
social_title: Suivez-nous aussi sur les réseaux sociaux
|
||||
label_address: Adresse
|
||||
label_vat: N° de TVA
|
||||
form_title: Envoyer un message
|
||||
|
||||
@@ -192,6 +192,7 @@ it:
|
||||
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
|
||||
company_title: Sede
|
||||
company_lead: Titolare del servizio e del trattamento dei dati.
|
||||
social_title: Seguici anche sui social
|
||||
label_address: Indirizzo
|
||||
label_vat: Partita IVA
|
||||
form_title: Invia un messaggio
|
||||
|
||||
@@ -24,6 +24,11 @@ de:
|
||||
manage_cookies: Cookies verwalten
|
||||
copyright: "© 2026 Emiliano Frascaro – USt-IdNr. 14230270960"
|
||||
responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen.
|
||||
social_nav: MatchLiveTV Social-Profile
|
||||
social_facebook: Facebook MatchLiveTV
|
||||
social_instagram: Instagram MatchLiveTV
|
||||
social_tiktok: TikTok MatchLiveTV
|
||||
social_youtube: YouTube MatchLiveTV
|
||||
cookie:
|
||||
title: Cookies und Datenschutz
|
||||
body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}.
|
||||
|
||||
@@ -24,6 +24,11 @@ en:
|
||||
manage_cookies: Manage cookies
|
||||
copyright: "© 2026 Emiliano Frascaro – VAT 14230270960"
|
||||
responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it.
|
||||
social_nav: MatchLiveTV social profiles
|
||||
social_facebook: Facebook MatchLiveTV
|
||||
social_instagram: Instagram MatchLiveTV
|
||||
social_tiktok: TikTok MatchLiveTV
|
||||
social_youtube: YouTube MatchLiveTV
|
||||
cookie:
|
||||
title: Cookies and privacy
|
||||
body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}.
|
||||
|
||||
@@ -24,6 +24,11 @@ es:
|
||||
manage_cookies: Gestionar cookies
|
||||
copyright: "© 2026 Emiliano Frascaro – NIF 14230270960"
|
||||
responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican.
|
||||
social_nav: Perfiles sociales MatchLiveTV
|
||||
social_facebook: Facebook MatchLiveTV
|
||||
social_instagram: Instagram MatchLiveTV
|
||||
social_tiktok: TikTok MatchLiveTV
|
||||
social_youtube: YouTube MatchLiveTV
|
||||
cookie:
|
||||
title: Cookies y privacidad
|
||||
body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}.
|
||||
|
||||
@@ -24,6 +24,11 @@ fr:
|
||||
manage_cookies: Gérer les cookies
|
||||
copyright: "© 2026 Emiliano Frascaro – TVA 14230270960"
|
||||
responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient.
|
||||
social_nav: Profils sociaux MatchLiveTV
|
||||
social_facebook: Facebook MatchLiveTV
|
||||
social_instagram: Instagram MatchLiveTV
|
||||
social_tiktok: TikTok MatchLiveTV
|
||||
social_youtube: YouTube MatchLiveTV
|
||||
cookie:
|
||||
title: Cookies et confidentialité
|
||||
body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}.
|
||||
|
||||
@@ -24,6 +24,11 @@ it:
|
||||
manage_cookies: Gestisci cookie
|
||||
copyright: "© 2026 Emiliano Frascaro – P. IVA 14230270960"
|
||||
responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.
|
||||
social_nav: Profili social MatchLiveTV
|
||||
social_facebook: Facebook MatchLiveTV
|
||||
social_instagram: Instagram MatchLiveTV
|
||||
social_tiktok: TikTok MatchLiveTV
|
||||
social_youtube: YouTube MatchLiveTV
|
||||
cookie:
|
||||
title: Cookie e privacy
|
||||
body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}.
|
||||
|
||||
@@ -76,6 +76,9 @@ Rails.application.routes.draw do
|
||||
|
||||
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
|
||||
|
||||
post "analytics/events", to: "analytics/events#create"
|
||||
post "analytics/snapshot", to: "analytics/snapshots#create"
|
||||
|
||||
namespace :admin do
|
||||
get "login", to: "auth#new", as: :login
|
||||
post "login", to: "auth#create"
|
||||
@@ -127,6 +130,8 @@ Rails.application.routes.draw do
|
||||
delete :clear_kill_switch
|
||||
end
|
||||
end
|
||||
get "analytics", to: "analytics#index", as: :analytics
|
||||
get "analytics/page", to: "analytics#show", as: :analytics_page
|
||||
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
||||
end
|
||||
|
||||
@@ -184,7 +189,7 @@ Rails.application.routes.draw do
|
||||
post "signup", to: "registrations#create"
|
||||
get "login", to: "sessions#new"
|
||||
post "login", to: "sessions#create"
|
||||
delete "logout", to: "sessions#destroy"
|
||||
match "logout", to: "sessions#destroy", via: %i[delete get]
|
||||
patch "locale", to: "locales#update", as: :locale
|
||||
get "password/forgot", to: "password_resets#new", as: :password_forgot
|
||||
post "password/forgot", to: "password_resets#create"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAnalyticsTables < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :analytics_events, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||
t.string :page_path, null: false
|
||||
t.string :device, null: false
|
||||
t.string :event_type, null: false
|
||||
t.decimal :x_pct, precision: 6, scale: 2
|
||||
t.decimal :y_pct, precision: 6, scale: 2
|
||||
t.decimal :scroll_pct, precision: 6, scale: 2
|
||||
t.datetime :occurred_at, null: false
|
||||
t.timestamps
|
||||
end
|
||||
add_index :analytics_events, :occurred_at
|
||||
add_index :analytics_events, %i[event_type occurred_at]
|
||||
|
||||
create_table :analytics_page_cells, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||
t.date :day, null: false
|
||||
t.string :page_path, null: false
|
||||
t.string :device, null: false
|
||||
t.integer :cell_x, null: false
|
||||
t.integer :cell_y, null: false
|
||||
t.integer :click_count, null: false, default: 0
|
||||
t.timestamps
|
||||
end
|
||||
add_index :analytics_page_cells,
|
||||
%i[day page_path device cell_x cell_y],
|
||||
unique: true,
|
||||
name: "index_analytics_page_cells_unique"
|
||||
|
||||
create_table :analytics_page_stats, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||
t.date :day, null: false
|
||||
t.string :page_path, null: false
|
||||
t.string :device, null: false
|
||||
t.integer :pageview_count, null: false, default: 0
|
||||
t.integer :scroll_samples, null: false, default: 0
|
||||
t.integer :scroll_sum_pct, null: false, default: 0
|
||||
t.integer :max_scroll_pct, null: false, default: 0
|
||||
t.timestamps
|
||||
end
|
||||
add_index :analytics_page_stats,
|
||||
%i[day page_path device],
|
||||
unique: true,
|
||||
name: "index_analytics_page_stats_unique"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddMoveHeatmapToAnalytics < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :analytics_events, :weight, :integer, null: false, default: 1
|
||||
add_column :analytics_page_cells, :move_count, :integer, null: false, default: 0
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class CreateAnalyticsPageSnapshots < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :analytics_page_snapshots, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||
t.string :page_path, null: false
|
||||
t.string :device, null: false
|
||||
t.integer :width, null: false, default: 0
|
||||
t.integer :height, null: false, default: 0
|
||||
t.datetime :captured_at, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :analytics_page_snapshots, %i[page_path device], unique: true, name: "index_analytics_page_snapshots_unique"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddClientTelemetryToStreamSessions < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
change_table :stream_sessions, bulk: true do |t|
|
||||
t.string :client_os
|
||||
t.string :app_version
|
||||
t.string :app_build
|
||||
t.string :device_manufacturer
|
||||
t.string :device_model
|
||||
t.string :os_version
|
||||
t.string :carrier
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,15 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddTemporaryReplayFieldsToRecordings < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
change_table :recordings, bulk: true do |t|
|
||||
t.string :storage_policy, null: false, default: "retained"
|
||||
t.datetime :temp_expires_at
|
||||
t.datetime :youtube_verified_at
|
||||
t.datetime :local_media_purged_at
|
||||
end
|
||||
|
||||
add_index :recordings, %i[storage_policy temp_expires_at],
|
||||
name: "index_recordings_on_storage_policy_and_temp_expires_at"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class AddIngestSnapshotToStreamSessions < ActiveRecord::Migration[7.2]
|
||||
def up
|
||||
add_column :stream_sessions, :ingest_slug, :string
|
||||
add_column :stream_sessions, :ingest_role, :string
|
||||
add_index :stream_sessions, :ingest_slug
|
||||
|
||||
execute <<~SQL
|
||||
UPDATE stream_sessions ss
|
||||
SET ingest_slug = sn.slug,
|
||||
ingest_role = sn.role
|
||||
FROM stream_nodes sn
|
||||
WHERE ss.stream_node_id = sn.id
|
||||
AND ss.ingest_slug IS NULL
|
||||
SQL
|
||||
|
||||
execute <<~SQL
|
||||
UPDATE stream_sessions ss
|
||||
SET ingest_slug = ev.slug
|
||||
FROM (
|
||||
SELECT DISTINCT ON (stream_session_id)
|
||||
stream_session_id,
|
||||
metadata->>'stream_node' AS slug
|
||||
FROM stream_events
|
||||
WHERE event_type = 'pairing'
|
||||
AND COALESCE(metadata->>'stream_node', '') <> ''
|
||||
ORDER BY stream_session_id, occurred_at ASC
|
||||
) ev
|
||||
WHERE ss.id = ev.stream_session_id
|
||||
AND ss.ingest_slug IS NULL
|
||||
SQL
|
||||
|
||||
execute <<~SQL
|
||||
UPDATE stream_sessions
|
||||
SET ingest_role = CASE
|
||||
WHEN ingest_slug = 'home' THEN 'home'
|
||||
WHEN ingest_slug LIKE 'ingest-lab%' THEN 'lab'
|
||||
WHEN ingest_slug LIKE 'ingest-%' THEN 'cloud'
|
||||
ELSE ingest_role
|
||||
END
|
||||
WHERE ingest_slug IS NOT NULL AND ingest_role IS NULL
|
||||
SQL
|
||||
end
|
||||
|
||||
def down
|
||||
remove_index :stream_sessions, :ingest_slug
|
||||
remove_column :stream_sessions, :ingest_role
|
||||
remove_column :stream_sessions, :ingest_slug
|
||||
end
|
||||
end
|
||||
Generated
+68
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_08_20_191000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -51,6 +51,58 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_191000) do
|
||||
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
|
||||
end
|
||||
|
||||
create_table "analytics_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "page_path", null: false
|
||||
t.string "device", null: false
|
||||
t.string "event_type", null: false
|
||||
t.decimal "x_pct", precision: 6, scale: 2
|
||||
t.decimal "y_pct", precision: 6, scale: 2
|
||||
t.decimal "scroll_pct", precision: 6, scale: 2
|
||||
t.datetime "occurred_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "weight", default: 1, null: false
|
||||
t.index ["event_type", "occurred_at"], name: "index_analytics_events_on_event_type_and_occurred_at"
|
||||
t.index ["occurred_at"], name: "index_analytics_events_on_occurred_at"
|
||||
end
|
||||
|
||||
create_table "analytics_page_cells", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.date "day", null: false
|
||||
t.string "page_path", null: false
|
||||
t.string "device", null: false
|
||||
t.integer "cell_x", null: false
|
||||
t.integer "cell_y", null: false
|
||||
t.integer "click_count", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "move_count", default: 0, null: false
|
||||
t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true
|
||||
end
|
||||
|
||||
create_table "analytics_page_snapshots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "page_path", null: false
|
||||
t.string "device", null: false
|
||||
t.integer "width", default: 0, null: false
|
||||
t.integer "height", default: 0, null: false
|
||||
t.datetime "captured_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["page_path", "device"], name: "index_analytics_page_snapshots_unique", unique: true
|
||||
end
|
||||
|
||||
create_table "analytics_page_stats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.date "day", null: false
|
||||
t.string "page_path", null: false
|
||||
t.string "device", null: false
|
||||
t.integer "pageview_count", default: 0, null: false
|
||||
t.integer "scroll_samples", default: 0, null: false
|
||||
t.integer "scroll_sum_pct", default: 0, null: false
|
||||
t.integer "max_scroll_pct", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["day", "page_path", "device"], name: "index_analytics_page_stats_unique", unique: true
|
||||
end
|
||||
|
||||
create_table "app_announcements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "kind", default: "info", null: false
|
||||
t.string "severity", default: "info", null: false
|
||||
@@ -286,10 +338,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_191000) do
|
||||
t.datetime "expiry_warning_sent_at"
|
||||
t.string "youtube_video_id"
|
||||
t.datetime "youtube_published_at"
|
||||
t.string "storage_policy", default: "retained", null: false
|
||||
t.datetime "temp_expires_at"
|
||||
t.datetime "youtube_verified_at"
|
||||
t.datetime "local_media_purged_at"
|
||||
t.index ["deleted_at"], name: "index_recordings_on_deleted_at"
|
||||
t.index ["expires_at"], name: "index_recordings_on_expires_at"
|
||||
t.index ["privacy_status"], name: "index_recordings_on_privacy_status"
|
||||
t.index ["storage_key"], name: "index_recordings_on_storage_key", unique: true, where: "(storage_key IS NOT NULL)"
|
||||
t.index ["storage_policy", "temp_expires_at"], name: "index_recordings_on_storage_policy_and_temp_expires_at"
|
||||
t.index ["stream_session_id"], name: "index_recordings_on_stream_session_id", unique: true
|
||||
t.index ["team_id", "status"], name: "index_recordings_on_team_id_and_status"
|
||||
t.index ["team_id"], name: "index_recordings_on_team_id"
|
||||
@@ -375,6 +432,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_191000) do
|
||||
t.uuid "stream_node_id"
|
||||
t.string "min_quality_preset", default: "auto", null: false
|
||||
t.boolean "audio_muted", default: false, null: false
|
||||
t.string "client_os"
|
||||
t.string "app_version"
|
||||
t.string "app_build"
|
||||
t.string "device_manufacturer"
|
||||
t.string "device_model"
|
||||
t.string "os_version"
|
||||
t.string "carrier"
|
||||
t.string "ingest_slug"
|
||||
t.string "ingest_role"
|
||||
t.index ["ingest_slug"], name: "index_stream_sessions_on_ingest_slug"
|
||||
t.index ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
||||
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
namespace :analytics do
|
||||
desc "Aggregate raw analytics events into heatmaps/stats"
|
||||
task aggregate: :environment do
|
||||
Analytics::AggregateJob.perform_now(purge: false)
|
||||
end
|
||||
|
||||
desc "Purge analytics aggregates older than 30 days and leftover raw events"
|
||||
task purge: :environment do
|
||||
Analytics::Aggregate.new.purge_old!
|
||||
end
|
||||
end
|
||||
@@ -20,4 +20,37 @@ namespace :recordings do
|
||||
orphan = Mediamtx::CleanupOrphanPaths.new.call
|
||||
puts "Path MediaMTX orfani rimossi: #{orphan.removed} (saltati: #{orphan.skipped})"
|
||||
end
|
||||
|
||||
desc "Elimina copie temporanee YouTube scadute (solo storage, non soft-delete)"
|
||||
task purge_temporary: :environment do
|
||||
count = Recordings::PurgeTemporaryMediaJob.new.perform
|
||||
puts "Purge temporary replays: processed=#{count}"
|
||||
end
|
||||
|
||||
desc "Audit dry-run: recording YouTube con ancora MP4 in Garage (duplicati storici). " \
|
||||
"Cleanup solo se APPLY=1 (non implementato di default — solo report)."
|
||||
task audit_youtube_duplicates: :environment do
|
||||
apply = ENV["APPLY"].to_s == "1"
|
||||
scope = Recording.not_deleted
|
||||
.where.not(youtube_video_id: [nil, ""])
|
||||
.where.not(storage_key: [nil, ""])
|
||||
.where("youtube_video_id NOT LIKE 'mock_%'")
|
||||
|
||||
puts "Audit YouTube duplicates (dry-run=#{!apply}): count=#{scope.count}"
|
||||
scope.find_each do |rec|
|
||||
puts [
|
||||
"id=#{rec.id}",
|
||||
"policy=#{rec.storage_policy}",
|
||||
"key=#{rec.storage_key}",
|
||||
"yt=#{rec.youtube_video_id}",
|
||||
"purged_at=#{rec.local_media_purged_at.inspect}",
|
||||
"temp_expires=#{rec.temp_expires_at.inspect}"
|
||||
].join(" ")
|
||||
end
|
||||
|
||||
if apply
|
||||
puts "APPLY=1 richiesto ma cleanup storico automatico NON eseguito " \
|
||||
"(fuori scope; solo dry-run supportato)."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/*! Admin heatmap overlay on captured page screenshot. */
|
||||
(function () {
|
||||
function colorFor(t) {
|
||||
if (t < 0.33) {
|
||||
var k = t / 0.33;
|
||||
return [67 + (255 - 67) * k, 160 + (235 - 160) * k, 71 * (1 - k)];
|
||||
}
|
||||
if (t < 0.66) {
|
||||
var k2 = (t - 0.33) / 0.33;
|
||||
return [255, 235 - (235 - 152) * k2, 0];
|
||||
}
|
||||
var k3 = (t - 0.66) / 0.34;
|
||||
return [255, 152 * (1 - k3), 0];
|
||||
}
|
||||
|
||||
function fit() {
|
||||
var canvas = document.getElementById("admin-heatmap");
|
||||
var stage = document.getElementById("admin-heatmap-stage");
|
||||
var scaler = document.getElementById("admin-heatmap-scaler");
|
||||
var shot = document.getElementById("admin-heatmap-shot");
|
||||
if (!canvas || !stage) return;
|
||||
|
||||
var designW = 1200;
|
||||
var designH = 900;
|
||||
if (scaler) {
|
||||
designW = parseInt(scaler.getAttribute("data-design-width"), 10) || designW;
|
||||
designH = parseInt(scaler.getAttribute("data-design-height"), 10) || designH;
|
||||
}
|
||||
if (shot && shot.naturalWidth > 0) {
|
||||
designW = shot.naturalWidth;
|
||||
designH = shot.naturalHeight;
|
||||
}
|
||||
|
||||
if (scaler) {
|
||||
scaler.style.width = designW + "px";
|
||||
scaler.style.height = designH + "px";
|
||||
var stageW = Math.max(stage.clientWidth, 320);
|
||||
var scale = Math.min(1, stageW / designW);
|
||||
scaler.style.transform = "scale(" + scale + ")";
|
||||
stage.style.height = Math.max(420, Math.round(designH * scale)) + "px";
|
||||
} else {
|
||||
designW = Math.max(stage.clientWidth, 320);
|
||||
designH = Math.max(stage.clientHeight, 480);
|
||||
}
|
||||
|
||||
var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40;
|
||||
var max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
|
||||
var cells = [];
|
||||
try {
|
||||
cells = JSON.parse(canvas.getAttribute("data-cells") || "[]");
|
||||
} catch (e) {
|
||||
cells = [];
|
||||
}
|
||||
|
||||
canvas.width = designW;
|
||||
canvas.height = designH;
|
||||
canvas.style.width = designW + "px";
|
||||
canvas.style.height = designH + "px";
|
||||
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, designW, designH);
|
||||
|
||||
var cellW = designW / grid;
|
||||
var cellH = designH / grid;
|
||||
var radius = Math.max(cellW, cellH) * 2.2;
|
||||
|
||||
cells.forEach(function (cell) {
|
||||
var intensity = Math.min(1, cell.c / max);
|
||||
if (intensity <= 0) return;
|
||||
var cx = (cell.x + 0.5) * cellW;
|
||||
var cy = (cell.y + 0.5) * cellH;
|
||||
var rgb = colorFor(intensity);
|
||||
var alpha = 0.28 + intensity * 0.55;
|
||||
var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||
grd.addColorStop(
|
||||
0,
|
||||
"rgba(" + Math.round(rgb[0]) + "," + Math.round(rgb[1]) + "," + Math.round(rgb[2]) + "," + alpha + ")"
|
||||
);
|
||||
grd.addColorStop(
|
||||
1,
|
||||
"rgba(" + Math.round(rgb[0]) + "," + Math.round(rgb[1]) + "," + Math.round(rgb[2]) + ",0)"
|
||||
);
|
||||
ctx.fillStyle = grd;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
function boot() {
|
||||
var shot = document.getElementById("admin-heatmap-shot");
|
||||
fit();
|
||||
if (shot) {
|
||||
if (shot.complete && shot.naturalWidth > 0) fit();
|
||||
else shot.addEventListener("load", fit);
|
||||
}
|
||||
window.addEventListener("resize", fit);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
})();
|
||||
@@ -234,6 +234,8 @@ body.admin-body {
|
||||
.badge--ingest-home { background: #1565c0; color: #e3f2fd; }
|
||||
.badge--ingest-lab { background: #6a1b9a; color: #f3e5f5; }
|
||||
.badge--ingest-cloud { background: #e65100; color: #fff3e0; }
|
||||
.badge--ended { background: #37474f; color: #eceff1; }
|
||||
.badge--error { background: #b71c1c; color: #ffebee; }
|
||||
|
||||
.admin-ingest {
|
||||
display: inline-flex;
|
||||
@@ -700,6 +702,36 @@ body.admin-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-locale-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.admin-locale-tab {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #444;
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-locale-tab.is-active {
|
||||
border-color: #ff2d2d;
|
||||
background: #3a1515;
|
||||
}
|
||||
|
||||
.admin-locale-panel {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-locale-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -713,3 +745,275 @@ body.admin-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-sessions-filters {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-filter-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-filter-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-filter-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.admin-filter-field input,
|
||||
.admin-filter-field select {
|
||||
font: inherit;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
background: #0d0d12;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 8px;
|
||||
padding: 0.45rem 0.55rem;
|
||||
}
|
||||
|
||||
.admin-filter-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.admin-filter-count {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.admin-table-strong {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.admin-table-sub {
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.admin-session-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-session-head-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.admin-session-match {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.admin-session-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-session-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-session-grid .panel h3,
|
||||
.panel > h3 {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.admin-kv {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.admin-kv > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(110px, 34%) 1fr;
|
||||
gap: 0.5rem 0.75rem;
|
||||
align-items: start;
|
||||
padding-bottom: 0.55rem;
|
||||
border-bottom: 1px solid rgba(42, 42, 54, 0.7);
|
||||
}
|
||||
|
||||
.admin-kv > div:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-kv dt {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.admin-kv dd {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.admin-mono--wrap {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-event-type {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-event-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.admin-event-meta__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 28%) 1fr;
|
||||
gap: 0.35rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.admin-event-meta dt {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-event-meta dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.admin-event-meta code {
|
||||
font-size: 0.78rem;
|
||||
color: #d7d7e2;
|
||||
}
|
||||
|
||||
.admin-table--events td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-links-panel {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.admin-scroll-bar {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: #0d0d12;
|
||||
border: 1px solid var(--card-border);
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-scroll-bar__avg {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #1565c0, #43a047);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.admin-scroll-bar__max {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
bottom: -2px;
|
||||
width: 2px;
|
||||
background: #e53935;
|
||||
transform: translateX(-1px);
|
||||
}
|
||||
|
||||
.admin-heatmap-stage {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
min-height: 70vh;
|
||||
max-height: 85vh;
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 10px;
|
||||
background: #d7dbe2;
|
||||
}
|
||||
|
||||
.admin-heatmap-scaler {
|
||||
position: relative;
|
||||
transform-origin: top left;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.admin-heatmap-shot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
vertical-align: top;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.admin-heatmap-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, #1e293b 0%, transparent 45%),
|
||||
radial-gradient(circle at 80% 10%, #0f172a 0%, transparent 40%),
|
||||
#0d0d12;
|
||||
}
|
||||
|
||||
.admin-heatmap-fallback-note {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
left: 1rem;
|
||||
top: 1rem;
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.admin-heatmap-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
function applyConsent(consent) {
|
||||
if (consent && consent.analytics) {
|
||||
loadGoogleAnalytics();
|
||||
if (typeof window.mltvSiteAnalyticsStart === "function") {
|
||||
window.mltvSiteAnalyticsStart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1307,7 +1307,7 @@ body.nav-menu-open { overflow: hidden; }
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.store-badges--footer {
|
||||
margin-top: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
.store-badges--footer .store-badge {
|
||||
min-height: 42px;
|
||||
@@ -2459,10 +2459,94 @@ body.nav-menu-open { overflow: hidden; }
|
||||
}
|
||||
.stripe-secure--compact i { font-size: 0.95rem; color: #888; }
|
||||
.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; }
|
||||
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; }
|
||||
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; }
|
||||
.site-footer__brand {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.site-footer__nav {
|
||||
flex: 0 1 auto;
|
||||
text-align: right;
|
||||
}
|
||||
.site-footer__apps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px 20px;
|
||||
width: 100%;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
|
||||
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
||||
.site-footer__legal p:last-child { margin-bottom: 0; }
|
||||
.site-social {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.site-social__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
opacity: 0.95;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.site-social__link:hover,
|
||||
.site-social__link:focus-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
.site-social__link--facebook {
|
||||
color: #1877F2;
|
||||
}
|
||||
.site-social__link--instagram {
|
||||
color: #dd2a7b;
|
||||
}
|
||||
.site-social__link--youtube {
|
||||
color: #FF0000;
|
||||
}
|
||||
.site-social__icon {
|
||||
display: block;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.site-social--footer {
|
||||
margin-top: 0;
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.site-social--panel {
|
||||
margin-top: 4px;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.site-social--panel .site-social__link {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.site-social--panel .site-social__icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.contacts-company,
|
||||
.contacts-social {
|
||||
max-width: 640px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
text-align: center;
|
||||
}
|
||||
.contacts-social h2 {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
.compare-table-wrap {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/*! Match Live TV first-party site analytics (click + mouse move + scroll). Requires analytics cookie consent. */
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var ENDPOINT = "/analytics/events";
|
||||
var SNAPSHOT_ENDPOINT = "/analytics/snapshot";
|
||||
var STORAGE_KEY = "mltv_cookie_consent";
|
||||
var COOKIE_NAME = "mltv_cookie_consent";
|
||||
var FLUSH_MS = 6000;
|
||||
var MAX_QUEUE = 60;
|
||||
var MOVE_THROTTLE_MS = 180;
|
||||
var MOVE_GRID = 40;
|
||||
var SNAPSHOT_DELAY_MS = 4000;
|
||||
|
||||
var queue = [];
|
||||
var moveBuckets = {};
|
||||
var maxScroll = 0;
|
||||
var flushTimer = null;
|
||||
var started = false;
|
||||
var lastMoveAt = 0;
|
||||
|
||||
function excludedPath(pathname) {
|
||||
var p = pathname || "/";
|
||||
if (p.indexOf("/admin") === 0) return true;
|
||||
if (p.indexOf("/regia") === 0) return true;
|
||||
if (p.indexOf("/analytics") === 0) return true;
|
||||
if (/^\/live\/[^/]+/.test(p)) return true;
|
||||
if (/^\/replay\/[^/]+/.test(p)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasAnalyticsConsent() {
|
||||
try {
|
||||
var raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
var parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.analytics) return true;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
var match = document.cookie.match(
|
||||
new RegExp("(?:^|; )" + COOKIE_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "=([^;]*)")
|
||||
);
|
||||
return !!(match && decodeURIComponent(match[1]) === "all");
|
||||
}
|
||||
|
||||
function deviceBucket() {
|
||||
var w = window.innerWidth || document.documentElement.clientWidth || 1024;
|
||||
if (w < 768) return "mobile";
|
||||
if (w < 1024) return "tablet";
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
function pageSize() {
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
return {
|
||||
width: Math.max(body.scrollWidth || 0, doc.scrollWidth || 0, window.innerWidth || 1),
|
||||
height: Math.max(body.scrollHeight || 0, doc.scrollHeight || 0, window.innerHeight || 1)
|
||||
};
|
||||
}
|
||||
|
||||
function scrollPct() {
|
||||
var doc = document.documentElement;
|
||||
var body = document.body;
|
||||
var scrollTop = window.pageYOffset || doc.scrollTop || body.scrollTop || 0;
|
||||
var height = Math.max(
|
||||
body.scrollHeight || 0,
|
||||
doc.scrollHeight || 0,
|
||||
body.offsetHeight || 0,
|
||||
doc.offsetHeight || 0,
|
||||
doc.clientHeight || 0
|
||||
);
|
||||
var view = window.innerHeight || doc.clientHeight || 0;
|
||||
var pct = ((scrollTop + view) / Math.max(height, 1)) * 100;
|
||||
if (scrollTop <= 0 && view >= height) return 100;
|
||||
return Math.max(0, Math.min(100, Math.round(pct)));
|
||||
}
|
||||
|
||||
function pushEvent(evt) {
|
||||
if (!started) return;
|
||||
queue.push(evt);
|
||||
if (queue.length >= MAX_QUEUE) flush(false);
|
||||
}
|
||||
|
||||
function flushMovesIntoQueue() {
|
||||
var keys = Object.keys(moveBuckets);
|
||||
if (!keys.length) return;
|
||||
var device = deviceBucket();
|
||||
var path = location.pathname;
|
||||
var ts = Date.now();
|
||||
keys.forEach(function (key) {
|
||||
var parts = key.split(",");
|
||||
var cx = parseInt(parts[0], 10);
|
||||
var cy = parseInt(parts[1], 10);
|
||||
var n = moveBuckets[key];
|
||||
if (!n) return;
|
||||
// cell center in %
|
||||
var x = ((cx + 0.5) / MOVE_GRID) * 100;
|
||||
var y = ((cy + 0.5) / MOVE_GRID) * 100;
|
||||
pushEvent({ type: "move", path: path, device: device, x: x, y: y, n: n, ts: ts });
|
||||
});
|
||||
moveBuckets = {};
|
||||
}
|
||||
|
||||
function flush(useBeacon) {
|
||||
flushMovesIntoQueue();
|
||||
if (!queue.length || !hasAnalyticsConsent()) {
|
||||
queue = [];
|
||||
return;
|
||||
}
|
||||
var batch = queue.splice(0, MAX_QUEUE);
|
||||
var body = JSON.stringify({ events: batch });
|
||||
try {
|
||||
if (useBeacon && navigator.sendBeacon) {
|
||||
var blob = new Blob([body], { type: "application/json" });
|
||||
navigator.sendBeacon(ENDPOINT, blob);
|
||||
return;
|
||||
}
|
||||
} catch (e) { /* fall through */ }
|
||||
try {
|
||||
fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: body,
|
||||
keepalive: true,
|
||||
credentials: "same-origin"
|
||||
}).catch(function () { /* ignore */ });
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
|
||||
function trackPageview() {
|
||||
pushEvent({
|
||||
type: "pageview",
|
||||
path: location.pathname,
|
||||
device: deviceBucket(),
|
||||
ts: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function onClick(event) {
|
||||
var target = event.target;
|
||||
if (!(target instanceof Element)) return;
|
||||
if (target.closest("input, textarea, select, [contenteditable='true']")) return;
|
||||
|
||||
var size = pageSize();
|
||||
var x = event.pageX;
|
||||
var y = event.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") return;
|
||||
|
||||
pushEvent({
|
||||
type: "click",
|
||||
path: location.pathname,
|
||||
device: deviceBucket(),
|
||||
x: Math.max(0, Math.min(100, (x / size.width) * 100)),
|
||||
y: Math.max(0, Math.min(100, (y / size.height) * 100)),
|
||||
ts: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function onMouseMove(event) {
|
||||
var now = Date.now();
|
||||
if (now - lastMoveAt < MOVE_THROTTLE_MS) return;
|
||||
lastMoveAt = now;
|
||||
var size = pageSize();
|
||||
var x = event.pageX;
|
||||
var y = event.pageY;
|
||||
if (typeof x !== "number" || typeof y !== "number") return;
|
||||
var xPct = Math.max(0, Math.min(100, (x / size.width) * 100));
|
||||
var yPct = Math.max(0, Math.min(100, (y / size.height) * 100));
|
||||
var cx = Math.min(MOVE_GRID - 1, Math.max(0, Math.floor((xPct / 100) * MOVE_GRID)));
|
||||
var cy = Math.min(MOVE_GRID - 1, Math.max(0, Math.floor((yPct / 100) * MOVE_GRID)));
|
||||
var key = cx + "," + cy;
|
||||
moveBuckets[key] = (moveBuckets[key] || 0) + 1;
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
var pct = scrollPct();
|
||||
if (pct > maxScroll) maxScroll = pct;
|
||||
}
|
||||
|
||||
function flushScroll() {
|
||||
if (maxScroll <= 0) return;
|
||||
pushEvent({
|
||||
type: "scroll",
|
||||
path: location.pathname,
|
||||
device: deviceBucket(),
|
||||
scroll: maxScroll,
|
||||
ts: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (flushTimer) return;
|
||||
flushTimer = setInterval(function () {
|
||||
flushScroll();
|
||||
flush(false);
|
||||
}, FLUSH_MS);
|
||||
}
|
||||
|
||||
function snapshotStorageKey() {
|
||||
return "mltv_snap:" + location.pathname + ":" + deviceBucket();
|
||||
}
|
||||
|
||||
function todayKey() {
|
||||
var d = new Date();
|
||||
return d.getUTCFullYear() + "-" + (d.getUTCMonth() + 1) + "-" + d.getUTCDate();
|
||||
}
|
||||
|
||||
function alreadyCapturedToday() {
|
||||
try {
|
||||
return localStorage.getItem(snapshotStorageKey()) === todayKey();
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function markCapturedToday() {
|
||||
try {
|
||||
localStorage.setItem(snapshotStorageKey(), todayKey());
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
function loadHtml2Canvas(cb) {
|
||||
if (window.html2canvas) {
|
||||
cb(window.html2canvas);
|
||||
return;
|
||||
}
|
||||
var s = document.createElement("script");
|
||||
s.src = "/vendor/html2canvas.min.js";
|
||||
s.async = true;
|
||||
s.onload = function () {
|
||||
if (window.html2canvas) cb(window.html2canvas);
|
||||
};
|
||||
s.onerror = function () { /* ignore */ };
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
function captureSnapshot() {
|
||||
if (!hasAnalyticsConsent() || alreadyCapturedToday()) return;
|
||||
loadHtml2Canvas(function (html2canvas) {
|
||||
var size = pageSize();
|
||||
html2canvas(document.documentElement, {
|
||||
scale: Math.min(1, 1400 / Math.max(size.width, 1)),
|
||||
useCORS: true,
|
||||
allowTaint: false,
|
||||
logging: false,
|
||||
windowWidth: size.width,
|
||||
windowHeight: size.height,
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
})
|
||||
.then(function (canvas) {
|
||||
if (!canvas || !canvas.toBlob) return;
|
||||
canvas.toBlob(
|
||||
function (blob) {
|
||||
if (!blob || blob.size < 8000 || blob.size > 1800000) return;
|
||||
var fd = new FormData();
|
||||
fd.append("image", blob, "snapshot.jpg");
|
||||
fd.append("path", location.pathname);
|
||||
fd.append("device", deviceBucket());
|
||||
fd.append("width", String(Math.round(size.width)));
|
||||
fd.append("height", String(Math.round(size.height)));
|
||||
fetch(SNAPSHOT_ENDPOINT, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
credentials: "same-origin"
|
||||
})
|
||||
.then(function (res) {
|
||||
if (res.ok) markCapturedToday();
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
},
|
||||
"image/jpeg",
|
||||
0.72
|
||||
);
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
});
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
if (excludedPath(location.pathname)) return;
|
||||
if (!hasAnalyticsConsent()) return;
|
||||
started = true;
|
||||
maxScroll = scrollPct();
|
||||
trackPageview();
|
||||
document.addEventListener("click", onClick, true);
|
||||
document.addEventListener("mousemove", onMouseMove, { passive: true });
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
document.addEventListener("visibilitychange", function () {
|
||||
if (document.visibilityState === "hidden") {
|
||||
flushScroll();
|
||||
flush(true);
|
||||
}
|
||||
});
|
||||
window.addEventListener("pagehide", function () {
|
||||
flushScroll();
|
||||
flush(true);
|
||||
});
|
||||
scheduleFlush();
|
||||
setTimeout(captureSnapshot, SNAPSHOT_DELAY_MS);
|
||||
}
|
||||
|
||||
window.mltvSiteAnalyticsStart = start;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})();
|
||||
+20
File diff suppressed because one or more lines are too long
@@ -34,4 +34,35 @@ RSpec.describe StreamSession do
|
||||
expect(session.mediamtx_api_base_url).to eq("http://10.0.0.2:9997")
|
||||
expect(session.mediamtx_internal_rtmp_url).to eq("rtmp://10.0.0.2:1935")
|
||||
end
|
||||
|
||||
it "keeps ingest snapshot after the stream node is destroyed" do
|
||||
node = 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",
|
||||
internal_rtmp_url: "rtmp://10.0.0.2:1935",
|
||||
max_publishers: 4,
|
||||
max_relays: 4
|
||||
)
|
||||
user = User.create!(email: "snap@example.com", name: "S", password: "Password123", role: "coach")
|
||||
club = Club.create!(name: "Club Snap", sport: "volleyball")
|
||||
team = club.teams.create!(name: "Team", sport: "volleyball", slug: "team-snap")
|
||||
match = team.matches.create!(opponent_name: "Opp", scheduled_at: 1.hour.from_now)
|
||||
session = StreamSession.create!(
|
||||
match: match, user: user, platform: "matchlivetv", status: "ended", stream_node: node
|
||||
)
|
||||
expect(session.ingest_slug).to eq("ingest-01")
|
||||
expect(session.ingest_role).to eq("cloud")
|
||||
|
||||
node.destroy!
|
||||
session.reload
|
||||
expect(session.stream_node).to be_nil
|
||||
expect(session.ingest_slug_display).to eq("ingest-01")
|
||||
expect(session.ingest_role_display).to eq("cloud")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Admin analytics", type: :request do
|
||||
let!(:admin) { AdminAccount.create!(username: "ops-analytics", password: "Password123") }
|
||||
|
||||
before do
|
||||
post admin_login_path, params: { username: admin.username, password: "Password123" }
|
||||
AnalyticsPageStat.create!(
|
||||
day: Time.zone.today,
|
||||
page_path: "/prezzi",
|
||||
device: "desktop",
|
||||
pageview_count: 12,
|
||||
scroll_samples: 10,
|
||||
scroll_sum_pct: 600,
|
||||
max_scroll_pct: 90
|
||||
)
|
||||
AnalyticsPageCell.create!(
|
||||
day: Time.zone.today,
|
||||
page_path: "/prezzi",
|
||||
device: "desktop",
|
||||
cell_x: 10,
|
||||
cell_y: 15,
|
||||
click_count: 4,
|
||||
move_count: 40
|
||||
)
|
||||
end
|
||||
|
||||
it "mostra l'elenco pagine" do
|
||||
get admin_analytics_path
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("/prezzi")
|
||||
expect(response.body).to include("12")
|
||||
expect(response.body).to include("40")
|
||||
end
|
||||
|
||||
it "mostra la heatmap di una pagina" do
|
||||
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("admin-heatmap")
|
||||
expect(response.body).to include("/prezzi")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Admin sessions index", type: :request do
|
||||
let!(:admin) { AdminAccount.create!(username: "ops-sessions", password: "Password123") }
|
||||
let!(:coach) do
|
||||
User.create!(email: "sessions-coach@test.it", name: "Coach", password: "Password123", role: "coach")
|
||||
end
|
||||
let!(:club_a) { Club.create!(name: "Tigers Club", sport: "volleyball") }
|
||||
let!(:club_b) { Club.create!(name: "Other Club", sport: "volleyball") }
|
||||
let!(:team_a) { club_a.teams.create!(name: "Tigers U16", sport_key: "pallavolo") }
|
||||
let!(:team_b) { club_b.teams.create!(name: "Other U14", sport_key: "pallavolo") }
|
||||
let!(:match_a) { team_a.matches.create!(opponent_name: "Rival A", sport_key: "pallavolo") }
|
||||
let!(:match_b) { team_b.matches.create!(opponent_name: "Rival B", sport_key: "pallavolo") }
|
||||
let!(:session_a) do
|
||||
StreamSession.create!(
|
||||
match: match_a,
|
||||
user: coach,
|
||||
platform: "matchlivetv",
|
||||
status: "ended",
|
||||
started_at: 2.hours.ago,
|
||||
ended_at: 1.hour.ago,
|
||||
total_duration_secs: 3600
|
||||
)
|
||||
end
|
||||
let!(:session_b) do
|
||||
StreamSession.create!(
|
||||
match: match_b,
|
||||
user: coach,
|
||||
platform: "youtube",
|
||||
status: "live",
|
||||
started_at: 30.minutes.ago
|
||||
)
|
||||
end
|
||||
|
||||
def login!
|
||||
post admin_login_path, params: { username: admin.username, password: "Password123" }
|
||||
end
|
||||
|
||||
before { login! }
|
||||
|
||||
it "mostra società, orari e filtra per testo" do
|
||||
get admin_sessions_path, params: { q: "Tigers" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Tigers Club")
|
||||
expect(response.body).to include("Tigers U16 vs Rival A")
|
||||
expect(response.body).not_to include("Other U14 vs Rival B")
|
||||
end
|
||||
|
||||
it "filtra per stato" do
|
||||
get admin_sessions_path, params: { status: "live" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Other U14 vs Rival B")
|
||||
expect(response.body).not_to include("Tigers U16 vs Rival A")
|
||||
end
|
||||
|
||||
it "mostra il dettaglio formattato" do
|
||||
get admin_session_path(session_a)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Tigers Club")
|
||||
expect(response.body).to include("Dettaglio sessione").or include("Session details")
|
||||
expect(response.body).to include(session_a.id)
|
||||
end
|
||||
|
||||
it "mostra lo snapshot ingest anche se il nodo è stato decommissionato" do
|
||||
node = StreamNode.create!(
|
||||
slug: "ingest-09",
|
||||
hostname: "ingest-09.mltv-stream.net",
|
||||
role: "cloud",
|
||||
status: "ready",
|
||||
provider: "hetzner",
|
||||
rtmp_base_url: "rtmp://ingest-09.mltv-stream.net:1935",
|
||||
hls_base_url: "https://collaudo.example/hls",
|
||||
api_base_url: "http://10.0.0.9:9997",
|
||||
max_publishers: 4,
|
||||
max_relays: 4
|
||||
)
|
||||
session_a.update!(stream_node: node)
|
||||
node.destroy!
|
||||
session_a.reload
|
||||
|
||||
get admin_sessions_path
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("ingest-09")
|
||||
expect(response.body).to include("Hetzner")
|
||||
|
||||
get admin_session_path(session_a)
|
||||
expect(response.body).to include("ingest-09")
|
||||
expect(response.body).to include("nodo rimosso").or include("node removed")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,69 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Analytics ingest", type: :request do
|
||||
it "accetta eventi validi, normalizza il path e aggrega" do
|
||||
post "/analytics/events",
|
||||
params: {
|
||||
events: [
|
||||
{
|
||||
type: "pageview",
|
||||
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
|
||||
device: "desktop",
|
||||
ts: Time.current.to_i * 1000
|
||||
},
|
||||
{
|
||||
type: "click",
|
||||
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
|
||||
device: "desktop",
|
||||
x: 25,
|
||||
y: 50,
|
||||
ts: Time.current.to_i * 1000
|
||||
},
|
||||
{
|
||||
type: "move",
|
||||
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
|
||||
device: "desktop",
|
||||
x: 40,
|
||||
y: 30,
|
||||
n: 12,
|
||||
ts: Time.current.to_i * 1000
|
||||
},
|
||||
{
|
||||
type: "scroll",
|
||||
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
|
||||
device: "desktop",
|
||||
scroll: 80,
|
||||
ts: Time.current.to_i * 1000
|
||||
}
|
||||
]
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:accepted)
|
||||
body = JSON.parse(response.body)
|
||||
expect(body["accepted"]).to eq(4)
|
||||
|
||||
expect(AnalyticsEvent.count).to eq(0)
|
||||
expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").pageview_count).to eq(1)
|
||||
expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").max_scroll_pct).to eq(80)
|
||||
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:click_count)).to eq(1)
|
||||
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
|
||||
end
|
||||
|
||||
it "rifiuta path esclusi" do
|
||||
post "/analytics/events",
|
||||
params: {
|
||||
events: [
|
||||
{ type: "pageview", path: "/admin/ops", device: "desktop", ts: Time.current.to_i * 1000 },
|
||||
{ type: "pageview", path: "/live/461d369a-104c-4d3c-8860-1518b4e95d35", device: "mobile", ts: Time.current.to_i * 1000 }
|
||||
]
|
||||
},
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:accepted)
|
||||
expect(JSON.parse(response.body)["accepted"]).to eq(0)
|
||||
expect(AnalyticsEvent.count).to eq(0)
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user