Compare commits

..
Author SHA1 Message Date
eminuxandCursor 05ef56c56d Aggiunge replay YouTube temporaneo, pull registrazioni dai CPX e snapshot ingest in admin.
Così overflow Hetzner e VOD YouTube restano in archivio dopo lo spegnimento del nodo, e la colonna ingest non si svuota.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 13:01:41 +02:00
eminuxandCursor 35dfa923e3 Usa packageName dinamico negli E2E Android per la variante collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:22:20 +02:00
eminuxandCursor 9d8b35c06c Salva telemetria client (OS, app, device, operatore) sulle sessioni per il debug admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 09:43:43 +02:00
eminuxandCursor 873e0ea55c Fix race analytics: retry su unique violation in aggregazione heatmap.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:23:02 +02:00
eminuxandCursor 4573edfedc Aggiunge i link ai profili social ufficiali nel footer e in Contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 16:10:18 +02:00
eminuxandCursor b301868774 Aggiunge regola Cursor per scalare i soft limit ingest col crescere dei clienti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 15:11:25 +02:00
eminuxandCursor b87a0f7bc0 Fix race create sessione su CPX: ready solo dopo MediaMTX.
I nodi cloud restano in provisioning finché :9997 risponde; retry su create_path e 503 retryable se l’ingest è ancora irraggiungibile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 14:06:47 +02:00
eminuxandCursor 70fccc493e Usa screenshot di pagina come sfondo heatmap al posto dell’iframe.
Cattura periodica client-side (con consenso), storage ActiveStorage e overlay allineato all’immagine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:14:47 +02:00
eminuxandCursor 406a90ae5b Bumpe site-analytics.js a v=3 per bypassare la cache NPM su collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:08:10 +02:00
eminuxandCursor 8e7bbdf8b2 Bumpe cache-bust della heatmap JS su collaudo (evita 404 NPM su ?v=1).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:07:07 +02:00
eminuxandCursor b84321346f Upgrade heatmaps stile Hotjar: movimenti mouse e anteprima pagina live.
Traccia i movimenti aggregati, overlay a gradienti sull’iframe della pagina e toggle click/move in admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:03:12 +02:00
eminuxandCursor 8a07c5d569 Aggiunge heatmaps first-party (click/scroll) con pagina admin Analytics.
Raccoglie eventi aggregati dietro consenso cookie, senza PII né tracking su admin/regia/live/replay; GA4 resta invariato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:50:15 +02:00
eminuxandCursor cc96c0396a Migliora sessioni admin e rende robusto il logout web.
Aggiunge filtri/colonne (società, orari) e dettaglio leggibile; evita 422 CSRF su logout e non cancella le cover slate custom in sync prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 20:41:12 +02:00
eminuxandCursor 79c48b226e Copia AAB/APK di release in native/android/dist con nome versionato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:53:53 +02:00
eminuxandCursor c9b75065bc Bump Android a 2.0.12-native (versionCode 33) per Play Store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:50:20 +02:00
eminuxandCursor 4ca9f3af8b Merge branch 'collaudo': avvisi multilanguage e allineamento sessioni admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:45:46 +02:00
eminuxandCursor 60dfe7a75b Allinea verticalmente le righe della tabella sessioni admin.
Il flex era sulla td e spezzava vertical-align; ora sta nel contenuto e l'ingest è su una riga.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:21:03 +02:00
138 changed files with 5908 additions and 327 deletions
@@ -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)
+98 -4
View File
@@ -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?
Recordings::NotifyReady.new(recording).call
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
+14
View File
@@ -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
+13
View File
@@ -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
+14
View File
@@ -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
+51 -4
View File
@@ -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
+17 -1
View File
@@ -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?
+107
View File
@@ -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
+134
View File
@@ -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
+36 -1
View File
@@ -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,7 +113,11 @@ module Recordings
end
def object_key(recording)
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
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)
@@ -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
+38 -3
View File
@@ -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?
+1 -1
View File
@@ -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>
+133 -32
View File
@@ -1,32 +1,133 @@
<h2><%= t("admin.sessions.index.title") %></h2>
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.sessions.index.table.match") %></th>
<th><%= t("admin.sessions.index.table.status") %></th>
<th><%= t("admin.sessions.index.table.ingest") %></th>
<th><%= t("admin.sessions.index.table.disconnects") %></th>
<th><%= t("admin.sessions.index.table.link") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @sessions.each do |s| %>
<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><%= render "admin/sessions/ingest_cell", session: s %></td>
<td><%= s.disconnection_count %></td>
<td>
<div class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</div>
</td>
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
</tr>
<% end %>
</tbody>
</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>
</tr>
</thead>
<tbody>
<% @sessions.each do |s| %>
<% club = s.match.team.club %>
<tr>
<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">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</div>
</td>
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.sessions.index.none") %></p>
<% end %>
</div>
+282 -44
View File
@@ -1,49 +1,287 @@
<h2><%= t("admin.sessions.show.title", id: @session.id) %></h2>
<p><%= t("admin.sessions.show.status_label") %> <strong><%= @session.status %></strong></p>
<% 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",
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>
<% 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? %>
<%= button_to t("admin.sessions.show.stop_button"),
stop_admin_session_path(@session),
method: :post,
class: "admin-btn admin-btn--danger admin-btn--sm",
form: { data: { turbo_confirm: t("admin.sessions.show.stop_confirm") } } %>
<% end %>
</div>
</div>
<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>
<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 %>
<% if @session.stream_node %>
<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 %>
</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.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>
<% 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 %>
<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.none") %></span>
<% end %>
</p>
<p><%= t("admin.sessions.show.rtmp_ingest") %> <code><%= @session.rtmp_ingest_url %></code></p>
<h3><%= t("admin.sessions.show.events_title") %></h3>
<table>
<thead>
<tr>
<th><%= t("admin.sessions.show.table.type") %></th>
<th><%= t("admin.sessions.show.table.when") %></th>
<th><%= t("admin.sessions.show.table.meta") %></th>
</tr>
</thead>
<tbody>
<% @events.each do |e| %>
<tr>
<td><%= e.event_type %></td>
<td><%= e.occurred_at %></td>
<td><%= e.metadata.to_json %></td>
</tr>
<% end %>
</tbody>
</table>
<section class="panel">
<h3><%= t("admin.sessions.show.events_title") %></h3>
<% 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>
<th><%= t("admin.sessions.show.table.when") %></th>
<th><%= t("admin.sessions.show.table.meta") %></th>
</tr>
</thead>
<tbody>
<% @events.each do |e| %>
<tr>
<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>
+3 -1
View File
@@ -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 %>
+4 -2
View File
@@ -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>
+29 -21
View File
@@ -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 2472, 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
+111 -2
View File
@@ -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
+111 -2
View File
@@ -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
+111 -2
View File
@@ -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
+111 -2
View File
@@ -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
+111 -2
View File
@@ -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
+6 -2
View File
@@ -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
+6 -2
View File
@@ -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
+6 -2
View File
@@ -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
+6 -2
View File
@@ -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
+6 -2
View File
@@ -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 lutente) 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 lID 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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+5
View File
@@ -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}.
+5
View File
@@ -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}.
+5
View File
@@ -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}.
+5
View File
@@ -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}.
+5
View File
@@ -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}.
+6 -1
View File
@@ -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
+68 -1
View File
@@ -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
+13
View File
@@ -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
+33
View File
@@ -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
+105
View File
@@ -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();
}
})();
+304
View File
@@ -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;
}
+3
View File
@@ -70,6 +70,9 @@
function applyConsent(consent) {
if (consent && consent.analytics) {
loadGoogleAnalytics();
if (typeof window.mltvSiteAnalyticsStart === "function") {
window.mltvSiteAnalyticsStart();
}
}
}
+86 -2
View File
@@ -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;
}
+312
View File
@@ -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();
}
})();
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