Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79850dfc2c | ||
|
|
645807b853 | ||
|
|
b08049cf69 | ||
|
|
29c8afb94d | ||
|
|
e0e07e5316 | ||
|
|
42dadb993d | ||
|
|
05ef56c56d | ||
|
|
35dfa923e3 | ||
|
|
9d8b35c06c | ||
|
|
873e0ea55c | ||
|
|
4573edfedc | ||
|
|
b301868774 |
@@ -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.
|
||||||
@@ -8,6 +8,7 @@ module Admin
|
|||||||
to: parse_date(params[:to]) || Time.zone.today,
|
to: parse_date(params[:to]) || Time.zone.today,
|
||||||
device: params[:device].presence
|
device: params[:device].presence
|
||||||
}
|
}
|
||||||
|
@analytics_preview_active = analytics_preview_active?
|
||||||
|
|
||||||
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
|
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
|
||||||
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||||
@@ -41,15 +42,23 @@ module Admin
|
|||||||
@page_path = params[:page_path].to_s
|
@page_path = params[:page_path].to_s
|
||||||
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
|
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
|
||||||
|
|
||||||
|
from = parse_date(params[:from]) || 7.days.ago.to_date
|
||||||
|
to = parse_date(params[:to]) || Time.zone.today
|
||||||
|
@device_tab_stats = device_tab_stats(@page_path, from, to)
|
||||||
|
device = resolve_heatmap_device(@device_tab_stats, params[:device].presence)
|
||||||
|
|
||||||
@filters = {
|
@filters = {
|
||||||
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
from: from,
|
||||||
to: parse_date(params[:to]) || Time.zone.today,
|
to: to,
|
||||||
device: params[:device].presence,
|
device: device,
|
||||||
layer: params[:layer].to_s
|
layer: params[:layer].to_s
|
||||||
}
|
}
|
||||||
|
|
||||||
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
|
cells = AnalyticsPageCell.where(
|
||||||
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
page_path: @page_path,
|
||||||
|
day: @filters[:from]..@filters[:to],
|
||||||
|
device: @filters[:device]
|
||||||
|
)
|
||||||
|
|
||||||
@click_total = cells.sum(:click_count)
|
@click_total = cells.sum(:click_count)
|
||||||
@move_total = cells.sum(:move_count)
|
@move_total = cells.sum(:move_count)
|
||||||
@@ -67,8 +76,11 @@ module Admin
|
|||||||
@max_weight = @cells.values.max.to_i
|
@max_weight = @cells.values.max.to_i
|
||||||
@total_points = @cells.values.sum
|
@total_points = @cells.values.sum
|
||||||
|
|
||||||
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
|
stats = AnalyticsPageStat.where(
|
||||||
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
page_path: @page_path,
|
||||||
|
day: @filters[:from]..@filters[:to],
|
||||||
|
device: @filters[:device]
|
||||||
|
)
|
||||||
@pageviews = stats.sum(:pageview_count)
|
@pageviews = stats.sum(:pageview_count)
|
||||||
@scroll_samples = stats.sum(:scroll_samples)
|
@scroll_samples = stats.sum(:scroll_samples)
|
||||||
@scroll_sum = stats.sum(:scroll_sum_pct)
|
@scroll_sum = stats.sum(:scroll_sum_pct)
|
||||||
@@ -78,8 +90,30 @@ module Admin
|
|||||||
@snapshot = find_snapshot(@page_path, @filters[:device])
|
@snapshot = find_snapshot(@page_path, @filters[:device])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def preview_enable
|
||||||
|
Analytics::Suppress.enable!(cookies)
|
||||||
|
redirect_to preview_return_to(params[:return_to]), notice: t("admin.analytics.preview.enabled")
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_disable
|
||||||
|
Analytics::Suppress.disable!(cookies)
|
||||||
|
redirect_to admin_analytics_path, notice: t("admin.analytics.preview.disabled")
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def analytics_preview_active?
|
||||||
|
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_return_to(value)
|
||||||
|
path = value.to_s.strip
|
||||||
|
return root_path if path.blank?
|
||||||
|
return path if path.start_with?("/") && !path.start_with?("//")
|
||||||
|
|
||||||
|
admin_analytics_path
|
||||||
|
end
|
||||||
|
|
||||||
def parse_date(value)
|
def parse_date(value)
|
||||||
return nil if value.blank?
|
return nil if value.blank?
|
||||||
|
|
||||||
@@ -88,14 +122,43 @@ module Admin
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_snapshot(page_path, device)
|
def device_tab_stats(page_path, from, to)
|
||||||
scope = AnalyticsPageSnapshot.where(page_path: page_path)
|
cell_totals = AnalyticsPageCell.where(page_path: page_path, day: from..to)
|
||||||
if device.present? && AnalyticsEvent::DEVICES.include?(device)
|
.group(:device)
|
||||||
snap = scope.find_by(device: device)
|
.pluck(
|
||||||
return snap if snap&.image&.attached?
|
:device,
|
||||||
|
Arel.sql("SUM(click_count)"),
|
||||||
|
Arel.sql("SUM(move_count)")
|
||||||
|
)
|
||||||
|
cell_by_device = cell_totals.to_h { |device, clicks, moves| [device, { clicks: clicks.to_i, moves: moves.to_i }] }
|
||||||
|
|
||||||
|
pageview_totals = AnalyticsPageStat.where(page_path: page_path, day: from..to)
|
||||||
|
.group(:device)
|
||||||
|
.sum(:pageview_count)
|
||||||
|
|
||||||
|
AnalyticsEvent::DEVICES.index_with do |device|
|
||||||
|
cells = cell_by_device[device] || { clicks: 0, moves: 0 }
|
||||||
|
cells.merge(pageviews: pageview_totals[device].to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_heatmap_device(tab_stats, requested)
|
||||||
|
if requested.present? && AnalyticsEvent::DEVICES.include?(requested)
|
||||||
|
return requested
|
||||||
end
|
end
|
||||||
|
|
||||||
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
|
AnalyticsEvent::DEVICES.max_by do |device|
|
||||||
|
stats = tab_stats[device]
|
||||||
|
stats[:clicks] + stats[:moves] + stats[:pageviews]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_snapshot(page_path, device)
|
||||||
|
return nil unless AnalyticsEvent::DEVICES.include?(device)
|
||||||
|
|
||||||
|
AnalyticsPageSnapshot.where(page_path: page_path, device: device)
|
||||||
|
.order(captured_at: :desc)
|
||||||
|
.detect { |snapshot| snapshot.image.attached? }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class CostEntriesController < Admin::BaseController
|
||||||
|
before_action :set_entry, only: %i[edit update destroy]
|
||||||
|
|
||||||
|
def new
|
||||||
|
@entry = PlatformCostEntry.new(
|
||||||
|
month: parse_month(params[:month]) || Time.zone.today.beginning_of_month,
|
||||||
|
label: PlatformCostEntry::DEFAULT_LABEL
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@entry = PlatformCostEntry.new(entry_attributes)
|
||||||
|
if @entry.save
|
||||||
|
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_created")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = @entry.errors.full_messages.join(", ")
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @entry.update(entry_attributes)
|
||||||
|
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_updated")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = @entry.errors.full_messages.join(", ")
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
month = @entry.month
|
||||||
|
@entry.destroy!
|
||||||
|
redirect_to admin_costs_path(month: month_param(month)), notice: t("admin.flash.cost_entry_destroyed")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_entry
|
||||||
|
@entry = PlatformCostEntry.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def entry_attributes
|
||||||
|
attrs = params.require(:platform_cost_entry).permit(:month, :label, :amount_euros, :notes)
|
||||||
|
if attrs[:amount_euros].present?
|
||||||
|
attrs[:amount_cents] = Billing::EuroAmount.to_cents(attrs.delete(:amount_euros))
|
||||||
|
end
|
||||||
|
attrs[:notes] = nil if attrs[:notes].blank?
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_month(value)
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def month_param(date)
|
||||||
|
date.strftime("%Y-%m")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class CostsController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
@month = parse_month(params[:month]) || Time.zone.today.beginning_of_month
|
||||||
|
analytics = Admin::CostAnalytics.new(month: @month)
|
||||||
|
@summary = analytics.summary
|
||||||
|
@trend = analytics.trend
|
||||||
|
@clubs = analytics.club_breakdown
|
||||||
|
@entries = PlatformCostEntry.for_month(@month).ordered
|
||||||
|
@month_options = month_options(@month)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def parse_month(value)
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def month_options(selected)
|
||||||
|
start = selected - 23.months
|
||||||
|
(0..23).map { |i| start + i.months }.reverse
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -79,14 +79,21 @@ module Admin
|
|||||||
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
|
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
|
||||||
scope = scope.where(stream_sessions: { status: @filters[:status] })
|
scope = scope.where(stream_sessions: { status: @filters[:status] })
|
||||||
end
|
end
|
||||||
if @filters[:platform].present? && StreamSession::PLATFORMS.include?(@filters[:platform])
|
if @filters[:platform].present? && StreamSession::LIVE_PLATFORMS.include?(@filters[:platform])
|
||||||
scope = scope.where(stream_sessions: { platform: @filters[:platform] })
|
scope = scope.where(stream_sessions: { platform: @filters[:platform] })
|
||||||
end
|
end
|
||||||
if @filters[:club_id].present?
|
if @filters[:club_id].present?
|
||||||
scope = scope.where(teams: { club_id: @filters[:club_id] })
|
scope = scope.where(teams: { club_id: @filters[:club_id] })
|
||||||
end
|
end
|
||||||
if @filters[:stream_node_id].present?
|
if @filters[:stream_node_id].present?
|
||||||
scope = scope.where(stream_sessions: { stream_node_id: @filters[:stream_node_id] })
|
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
|
end
|
||||||
if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
|
if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
|
||||||
scope = scope.where(
|
scope = scope.where(
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
module Analytics
|
module Analytics
|
||||||
class EventsController < ActionController::API
|
class EventsController < ActionController::API
|
||||||
|
include ActionController::Cookies
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
if analytics_suppressed?
|
||||||
|
return render json: { accepted: 0, rejected: 0, suppressed: true }, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
payload = parse_payload
|
payload = parse_payload
|
||||||
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
|
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
|
||||||
|
|
||||||
@@ -15,6 +21,10 @@ module Analytics
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def analytics_suppressed?
|
||||||
|
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
|
||||||
|
end
|
||||||
|
|
||||||
def parse_payload
|
def parse_payload
|
||||||
body = request.request_parameters
|
body = request.request_parameters
|
||||||
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
|
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
module Analytics
|
module Analytics
|
||||||
class SnapshotsController < ActionController::API
|
class SnapshotsController < ActionController::API
|
||||||
|
include ActionController::Cookies
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
if analytics_suppressed?
|
||||||
|
return render json: { ok: true, skipped: true, suppressed: true }, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
result = Analytics::SnapshotIngest.new(
|
result = Analytics::SnapshotIngest.new(
|
||||||
path: params[:path] || params[:page_path],
|
path: params[:path] || params[:page_path],
|
||||||
device: params[:device],
|
device: params[:device],
|
||||||
@@ -17,5 +23,11 @@ module Analytics
|
|||||||
|
|
||||||
render json: { ok: true, skipped: result.skipped }, status: :accepted
|
render json: { ok: true, skipped: result.skipped }, status: :accepted
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def analytics_suppressed?
|
||||||
|
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -97,10 +97,11 @@ module Api
|
|||||||
replay_url: recording.replay_url,
|
replay_url: recording.replay_url,
|
||||||
playback_url: recording.playback_stream_url,
|
playback_url: recording.playback_stream_url,
|
||||||
thumbnail_url: recording.thumbnail_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_video_id: recording.youtube_video_id,
|
||||||
youtube_watch_url: recording.youtube_watch_url,
|
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: recording.source_platform,
|
||||||
source_platform_label: recording.source_platform_label,
|
source_platform_label: recording.source_platform_label,
|
||||||
expires_at: recording.expires_at,
|
expires_at: recording.expires_at,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ module Api
|
|||||||
thermal_state: sanitized_thermal_state,
|
thermal_state: sanitized_thermal_state,
|
||||||
last_seen_at: Time.current
|
last_seen_at: Time.current
|
||||||
)
|
)
|
||||||
|
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
|
||||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||||
SessionChannel.broadcast_message(@session, state.as_cable_payload)
|
SessionChannel.broadcast_message(@session, state.as_cable_payload)
|
||||||
head :no_content
|
head :no_content
|
||||||
@@ -180,7 +181,12 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def session_params
|
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
|
end
|
||||||
|
|
||||||
def score_sync_params
|
def score_sync_params
|
||||||
|
|||||||
@@ -171,12 +171,13 @@ module Api
|
|||||||
replay_url: recording.replay_url,
|
replay_url: recording.replay_url,
|
||||||
playback_url: recording.playback_stream_url,
|
playback_url: recording.playback_stream_url,
|
||||||
thumbnail_url: recording.thumbnail_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,
|
view_count: recording.view_count,
|
||||||
views_label: recording.views_label,
|
views_label: recording.views_label,
|
||||||
youtube_video_id: recording.youtube_video_id,
|
youtube_video_id: recording.youtube_video_id,
|
||||||
youtube_watch_url: recording.youtube_watch_url,
|
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: recording.source_platform,
|
||||||
source_platform_label: recording.source_platform_label,
|
source_platform_label: recording.source_platform_label,
|
||||||
expires_at: recording.expires_at,
|
expires_at: recording.expires_at,
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
module AdminHelper
|
module AdminHelper
|
||||||
|
def format_euros(cents, precision: 2)
|
||||||
|
return I18n.t("admin.common.dash") if cents.nil?
|
||||||
|
|
||||||
|
format("%.*f €", precision, cents.to_f / 100.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_hours(hours)
|
||||||
|
return I18n.t("admin.common.dash") if hours.nil? || hours.to_f <= 0
|
||||||
|
|
||||||
|
total_minutes = (hours.to_f * 60).round
|
||||||
|
format_duration_minutes(total_minutes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_month_label(month)
|
||||||
|
I18n.l(month, format: "%B %Y")
|
||||||
|
end
|
||||||
|
|
||||||
def format_bytes(bytes)
|
def format_bytes(bytes)
|
||||||
return "—" if bytes.nil?
|
return "—" if bytes.nil?
|
||||||
|
|
||||||
@@ -30,8 +47,9 @@ module AdminHelper
|
|||||||
links
|
links
|
||||||
end
|
end
|
||||||
|
|
||||||
def admin_session_ingest_badge_class(node)
|
def admin_session_ingest_badge_class(role_or_node)
|
||||||
case node.role
|
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 "home" then "badge--ingest-home"
|
||||||
when "lab" then "badge--ingest-lab"
|
when "lab" then "badge--ingest-lab"
|
||||||
when "cloud" then "badge--ingest-cloud"
|
when "cloud" then "badge--ingest-cloud"
|
||||||
@@ -39,8 +57,9 @@ module AdminHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def admin_session_ingest_role_label(node)
|
def admin_session_ingest_role_label(role_or_node)
|
||||||
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize)
|
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
|
end
|
||||||
|
|
||||||
def admin_session_status_badge_class(status)
|
def admin_session_status_badge_class(status)
|
||||||
@@ -81,6 +100,29 @@ module AdminHelper
|
|||||||
end
|
end
|
||||||
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)
|
def admin_format_event_meta(metadata)
|
||||||
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
|
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
module Recordings
|
||||||
class PostProcessJob
|
class PostProcessJob
|
||||||
include Sidekiq::Job
|
include Sidekiq::Job
|
||||||
@@ -7,10 +9,30 @@ module Recordings
|
|||||||
recording = Recording.find_by(id: recording_id)
|
recording = Recording.find_by(id: recording_id)
|
||||||
return unless recording&.ready?
|
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 unless recording.auto_publish_youtube?
|
||||||
return if recording.youtube_video_id.present?
|
return if recording.youtube_video_id.present?
|
||||||
|
return if recording.source_platform == "youtube"
|
||||||
|
|
||||||
Recordings::PublishToYoutubeJob.perform_async(recording.id)
|
Recordings::PublishToYoutubeJob.perform_async(recording.id)
|
||||||
end
|
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,56 @@
|
|||||||
|
# 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?
|
||||||
|
recording.reload
|
||||||
|
embeddable = recording.metadata.is_a?(Hash) && recording.metadata.dig("youtube", "embeddable") != false
|
||||||
|
if embeddable
|
||||||
|
Recordings::ClearTemporaryMediaJob.perform_async(recording.id, "verified")
|
||||||
|
else
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[Recordings::VerifyYoutubeReplayJob] keep_temp recording=#{recording.id} " \
|
||||||
|
"youtube_not_embeddable"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
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
|
INTERVAL_SECS = ENV.fetch("STREAM_AUTOSCALE_INTERVAL_SECS", "60").to_i
|
||||||
REDIS_CHAIN_KEY = "streams:autoscaler:chain"
|
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
|
def self.ensure_chain
|
||||||
return unless redis
|
return unless redis
|
||||||
@@ -17,6 +19,16 @@ module Streams
|
|||||||
perform_in(INTERVAL_SECS)
|
perform_in(INTERVAL_SECS)
|
||||||
end
|
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
|
def self.redis
|
||||||
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
rescue Redis::CannotConnectError
|
rescue Redis::CannotConnectError
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PlatformCostEntry < ApplicationRecord
|
||||||
|
DEFAULT_LABEL = "Piattaforma produzione"
|
||||||
|
|
||||||
|
validates :month, presence: true
|
||||||
|
validates :label, presence: true, length: { maximum: 120 }
|
||||||
|
validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
|
||||||
|
before_validation :normalize_month
|
||||||
|
|
||||||
|
scope :for_month, ->(date) { where(month: date.to_date.beginning_of_month) }
|
||||||
|
scope :ordered, -> { order(month: :desc, created_at: :desc) }
|
||||||
|
|
||||||
|
def month=(value)
|
||||||
|
if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}\z/)
|
||||||
|
super(Date.strptime(value, "%Y-%m"))
|
||||||
|
else
|
||||||
|
super(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def amount_euros
|
||||||
|
amount_cents.to_f / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_month
|
||||||
|
return if month.blank?
|
||||||
|
|
||||||
|
parsed =
|
||||||
|
case month
|
||||||
|
when Date
|
||||||
|
month
|
||||||
|
when Time, ActiveSupport::TimeWithZone
|
||||||
|
month.to_date
|
||||||
|
when String
|
||||||
|
if month.match?(/\A\d{4}-\d{2}\z/)
|
||||||
|
Date.strptime(month, "%Y-%m")
|
||||||
|
else
|
||||||
|
Date.parse(month)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Date.parse(month.to_s)
|
||||||
|
end
|
||||||
|
self.month = parsed.beginning_of_month
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
errors.add(:month, :invalid)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,8 @@ class Recording < ApplicationRecord
|
|||||||
STATUSES = %w[processing ready expired failed].freeze
|
STATUSES = %w[processing ready expired failed].freeze
|
||||||
PRIVACY_STATUSES = %w[public unlisted].freeze
|
PRIVACY_STATUSES = %w[public unlisted].freeze
|
||||||
STORAGE_BACKENDS = %w[local s3].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 :stream_session
|
||||||
belongs_to :team
|
belongs_to :team
|
||||||
@@ -9,6 +11,7 @@ class Recording < ApplicationRecord
|
|||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||||
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
|
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
|
||||||
|
validates :storage_policy, inclusion: { in: STORAGE_POLICIES }
|
||||||
|
|
||||||
scope :not_deleted, -> { where(deleted_at: nil) }
|
scope :not_deleted, -> { where(deleted_at: nil) }
|
||||||
scope :ready, lambda {
|
scope :ready, lambda {
|
||||||
@@ -23,7 +26,16 @@ class Recording < ApplicationRecord
|
|||||||
ready.where(expires_at: ..days.days.from_now)
|
ready.where(expires_at: ..days.days.from_now)
|
||||||
}
|
}
|
||||||
scope :expired_pending_purge, lambda {
|
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|
|
scope :search_replays, lambda { |query|
|
||||||
q = query.to_s.strip
|
q = query.to_s.strip
|
||||||
@@ -46,19 +58,30 @@ class Recording < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def playback_stream_url
|
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"
|
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream"
|
||||||
end
|
end
|
||||||
|
|
||||||
def thumbnail_url
|
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?
|
return nil unless thumbnail_storage_key.present? && stream_session_id.present?
|
||||||
|
|
||||||
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
|
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
|
||||||
end
|
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
|
def download_api_path
|
||||||
return nil unless ready?
|
return nil unless ready? && storage_key.present?
|
||||||
|
|
||||||
"/api/v1/recordings/#{id}/download"
|
"/api/v1/recordings/#{id}/download"
|
||||||
end
|
end
|
||||||
@@ -70,6 +93,30 @@ class Recording < ApplicationRecord
|
|||||||
"https://www.youtube.com/watch?v=#{youtube_video_id}"
|
"https://www.youtube.com/watch?v=#{youtube_video_id}"
|
||||||
end
|
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?
|
def ready?
|
||||||
status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
|
status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
|
||||||
end
|
end
|
||||||
@@ -154,7 +201,7 @@ class Recording < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def playable_on_site?
|
def playable_on_site?
|
||||||
ready? && storage_key.present?
|
available_in_archive?
|
||||||
end
|
end
|
||||||
|
|
||||||
def days_until_expiry
|
def days_until_expiry
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
class StreamSession < ApplicationRecord
|
class StreamSession < ApplicationRecord
|
||||||
include AASM
|
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
|
STATUSES = %w[idle connecting live reconnecting paused ended error].freeze
|
||||||
PRIVACY_STATUSES = %w[public unlisted private].freeze
|
PRIVACY_STATUSES = %w[public unlisted private].freeze
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class StreamSession < ApplicationRecord
|
|||||||
|
|
||||||
before_validation :normalize_privacy_status
|
before_validation :normalize_privacy_status
|
||||||
before_validation :ensure_publish_token, on: :create
|
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 :broadcasting, -> { where(status: %w[live connecting reconnecting paused]) }
|
||||||
scope :publicly_listed, -> { where(privacy_status: "public") }
|
scope :publicly_listed, -> { where(privacy_status: "public") }
|
||||||
@@ -74,6 +76,14 @@ class StreamSession < ApplicationRecord
|
|||||||
end
|
end
|
||||||
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
|
def rtmp_ingest_url
|
||||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||||
@@ -208,6 +218,11 @@ class StreamSession < ApplicationRecord
|
|||||||
self.privacy_status = "unlisted" if privacy_status == "private"
|
self.privacy_status = "unlisted" if privacy_status == "private"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def snapshot_ingest_from_node
|
||||||
|
self.ingest_slug = stream_node.slug
|
||||||
|
self.ingest_role = stream_node.role
|
||||||
|
end
|
||||||
|
|
||||||
def record_ended_timestamps!
|
def record_ended_timestamps!
|
||||||
now = Time.current
|
now = Time.current
|
||||||
update!(ended_at: now) if ended_at.nil?
|
update!(ended_at: now) if ended_at.nil?
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class CostAnalytics
|
||||||
|
TREND_MONTHS = 12
|
||||||
|
|
||||||
|
def initialize(month:)
|
||||||
|
@month = month.to_date.beginning_of_month
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary
|
||||||
|
build_period(@month)
|
||||||
|
end
|
||||||
|
|
||||||
|
def trend(months: TREND_MONTHS)
|
||||||
|
start_month = @month - (months - 1).months
|
||||||
|
months_list = (0...months).map { |i| start_month + i.months }
|
||||||
|
months_list.map { |m| build_period(m) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def club_breakdown
|
||||||
|
range = month_range(@month)
|
||||||
|
rows = session_rows(range)
|
||||||
|
total_secs = rows.sum { |row| row.total_secs.to_i }
|
||||||
|
total_cost_cents = PlatformCostEntry.for_month(@month).sum(:amount_cents)
|
||||||
|
revenue_by_club = revenue_by_club(range)
|
||||||
|
storage_by_club = storage_by_club_index
|
||||||
|
club_ids = rows.map(&:club_id)
|
||||||
|
clubs = Club.where(id: club_ids).includes(subscription: :plan).index_by(&:id)
|
||||||
|
|
||||||
|
rows.map do |row|
|
||||||
|
club = clubs[row.club_id]
|
||||||
|
secs = row.total_secs.to_i
|
||||||
|
hours = secs / 3600.0
|
||||||
|
sessions_count = row.sessions_count.to_i
|
||||||
|
share = total_secs.positive? ? secs.to_f / total_secs : 0.0
|
||||||
|
allocated_cents = (total_cost_cents * share).round
|
||||||
|
revenue_cents = revenue_by_club[row.club_id].to_i
|
||||||
|
storage_bytes = storage_by_club[row.club_id].to_i
|
||||||
|
|
||||||
|
{
|
||||||
|
club_id: row.club_id,
|
||||||
|
club_name: row.club_name,
|
||||||
|
plan_slug: club&.subscription&.plan&.slug,
|
||||||
|
sessions: sessions_count,
|
||||||
|
hours: hours.round(2),
|
||||||
|
hours_share_pct: (share * 100).round(1),
|
||||||
|
allocated_cost_cents: allocated_cents,
|
||||||
|
revenue_cents: revenue_cents,
|
||||||
|
margin_cents: revenue_cents - allocated_cents,
|
||||||
|
cost_per_hour_cents: hours.positive? ? (allocated_cents / hours).round : nil,
|
||||||
|
cost_per_session_cents: sessions_count.positive? ? (allocated_cents / sessions_count) : nil,
|
||||||
|
storage_bytes: storage_bytes
|
||||||
|
}
|
||||||
|
end.sort_by { |row| [-row[:hours], row[:club_name]] }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_period(month)
|
||||||
|
range = month_range(month)
|
||||||
|
cost_cents = PlatformCostEntry.for_month(month).sum(:amount_cents)
|
||||||
|
sessions_scope = ended_sessions.where(ended_at: range)
|
||||||
|
total_secs = sessions_scope.sum(:total_duration_secs).to_i
|
||||||
|
hours = total_secs / 3600.0
|
||||||
|
sessions = sessions_scope.count
|
||||||
|
clubs_active = distinct_active_clubs(range)
|
||||||
|
revenue_cents = Billing::Payment.where(status: "paid", paid_at: range).sum(:amount_cents)
|
||||||
|
storage_bytes = Recording.ready.sum(:byte_size).to_i
|
||||||
|
storage_gb = storage_bytes.positive? ? storage_bytes / (1024.0**3) : 0.0
|
||||||
|
|
||||||
|
kpis = compute_kpis(
|
||||||
|
cost_cents: cost_cents,
|
||||||
|
hours: hours,
|
||||||
|
sessions: sessions,
|
||||||
|
clubs_active: clubs_active,
|
||||||
|
revenue_cents: revenue_cents,
|
||||||
|
storage_gb: storage_gb
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
month: month,
|
||||||
|
cost_cents: cost_cents,
|
||||||
|
sessions: sessions,
|
||||||
|
hours: hours.round(2),
|
||||||
|
clubs_active: clubs_active,
|
||||||
|
revenue_cents: revenue_cents,
|
||||||
|
storage_bytes: storage_bytes,
|
||||||
|
**kpis
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def compute_kpis(cost_cents:, hours:, sessions:, clubs_active:, revenue_cents:, storage_gb:)
|
||||||
|
margin_cents = revenue_cents.to_i - cost_cents.to_i
|
||||||
|
margin_pct = revenue_cents.to_i.positive? ? ((margin_cents.to_f / revenue_cents.to_i) * 100).round(1) : nil
|
||||||
|
hours_f = hours.to_f
|
||||||
|
sessions_i = sessions.to_i
|
||||||
|
clubs_i = clubs_active.to_i
|
||||||
|
storage_f = storage_gb.to_f
|
||||||
|
cost_i = cost_cents.to_i
|
||||||
|
|
||||||
|
{
|
||||||
|
cost_per_hour_cents: hours_f.positive? ? (cost_i / hours_f).round : nil,
|
||||||
|
cost_per_session_cents: sessions_i.positive? ? (cost_i / sessions_i) : nil,
|
||||||
|
cost_per_club_cents: clubs_i.positive? ? (cost_i / clubs_i) : nil,
|
||||||
|
revenue_per_hour_cents: hours_f.positive? ? (revenue_cents.to_i / hours_f).round : nil,
|
||||||
|
cost_per_gb_cents: storage_f.positive? ? (cost_i / storage_f).round : nil,
|
||||||
|
margin_cents: margin_cents,
|
||||||
|
margin_pct: margin_pct
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def month_range(month)
|
||||||
|
month.beginning_of_month.beginning_of_day..month.end_of_month.end_of_day
|
||||||
|
end
|
||||||
|
|
||||||
|
def ended_sessions
|
||||||
|
StreamSession.where(status: "ended")
|
||||||
|
end
|
||||||
|
|
||||||
|
def distinct_active_clubs(range)
|
||||||
|
ended_sessions
|
||||||
|
.where(ended_at: range)
|
||||||
|
.joins(match: { team: :club })
|
||||||
|
.distinct
|
||||||
|
.count("clubs.id")
|
||||||
|
end
|
||||||
|
|
||||||
|
def session_rows(range)
|
||||||
|
ended_sessions
|
||||||
|
.where(ended_at: range)
|
||||||
|
.joins(match: { team: :club })
|
||||||
|
.group("clubs.id", "clubs.name")
|
||||||
|
.select(
|
||||||
|
"clubs.id AS club_id",
|
||||||
|
"clubs.name AS club_name",
|
||||||
|
"COUNT(stream_sessions.id) AS sessions_count",
|
||||||
|
"SUM(stream_sessions.total_duration_secs) AS total_secs"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def revenue_by_club(range)
|
||||||
|
Billing::Payment.where(status: "paid", paid_at: range).group(:club_id).sum(:amount_cents)
|
||||||
|
end
|
||||||
|
|
||||||
|
def storage_by_club_index
|
||||||
|
Recording.ready.joins(:team).group("teams.club_id").sum(:byte_size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
module Analytics
|
module Analytics
|
||||||
class Aggregate
|
class Aggregate
|
||||||
BATCH = 500
|
BATCH = 500
|
||||||
|
UPSERT_RETRIES = 3
|
||||||
|
|
||||||
def call
|
def call
|
||||||
loop do
|
loop do
|
||||||
@@ -45,13 +46,15 @@ module Analytics
|
|||||||
|
|
||||||
now = Time.current
|
now = Time.current
|
||||||
grouped.each do |(day, page_path, device, cell_x, cell_y), count|
|
grouped.each do |(day, page_path, device, cell_x, cell_y), count|
|
||||||
cell = AnalyticsPageCell.find_or_initialize_by(
|
with_unique_retry do
|
||||||
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
|
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[counter_attr] = cell[counter_attr].to_i + count
|
||||||
cell.updated_at = now
|
cell.created_at ||= now
|
||||||
cell.save!
|
cell.updated_at = now
|
||||||
|
cell.save!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -76,14 +79,28 @@ module Analytics
|
|||||||
|
|
||||||
now = Time.current
|
now = Time.current
|
||||||
grouped.each do |(day, page_path, device), vals|
|
grouped.each do |(day, page_path, device), vals|
|
||||||
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
|
with_unique_retry do
|
||||||
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
|
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
|
||||||
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
|
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
|
||||||
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
|
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
|
||||||
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
|
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
|
||||||
stat.created_at ||= now
|
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
|
||||||
stat.updated_at = now
|
stat.created_at ||= now
|
||||||
stat.save!
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -31,7 +31,15 @@ module Analytics
|
|||||||
end
|
end
|
||||||
|
|
||||||
AnalyticsEvent.insert_all(rows) if rows.any?
|
AnalyticsEvent.insert_all(rows) if rows.any?
|
||||||
Analytics::Aggregate.new.call 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)
|
Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Analytics
|
||||||
|
module Suppress
|
||||||
|
COOKIE_NAME = "mltv_analytics_suppress"
|
||||||
|
COOKIE_MAX_AGE = 7 * 24 * 60 * 60
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def active?(cookie_value)
|
||||||
|
cookie_value.to_s == "1"
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable!(cookie_jar)
|
||||||
|
cookie_jar[COOKIE_NAME] = cookie_options(value: "1", expires: COOKIE_MAX_AGE.seconds.from_now)
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable!(cookie_jar)
|
||||||
|
cookie_jar.delete(
|
||||||
|
COOKIE_NAME,
|
||||||
|
path: "/",
|
||||||
|
same_site: :lax,
|
||||||
|
secure: cookie_secure?
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cookie_options(value:, expires:)
|
||||||
|
{
|
||||||
|
value: value,
|
||||||
|
expires: expires,
|
||||||
|
path: "/",
|
||||||
|
httponly: true,
|
||||||
|
same_site: :lax,
|
||||||
|
secure: cookie_secure?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def cookie_secure?
|
||||||
|
Rails.application.config.force_ssl || Rails.env.production?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -54,6 +54,8 @@ module Ops
|
|||||||
def process_finding(finding)
|
def process_finding(finding)
|
||||||
if finding.healthy
|
if finding.healthy
|
||||||
Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint)
|
Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint)
|
||||||
|
# overflow usa fingerprint diverse (at_max / orphan_idle / budget) rispetto al check sano
|
||||||
|
Ops::IncidentRecorder.resolve_kind(finding.kind) if finding.kind == "stream_overflow"
|
||||||
else
|
else
|
||||||
Ops::IncidentRecorder.record(
|
Ops::IncidentRecorder.record(
|
||||||
kind: finding.kind,
|
kind: finding.kind,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ module Ops
|
|||||||
def resolve(fingerprint:)
|
def resolve(fingerprint:)
|
||||||
new.resolve(fingerprint: fingerprint)
|
new.resolve(fingerprint: fingerprint)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve_kind(kind)
|
||||||
|
new.resolve_kind(kind)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def record(finding)
|
def record(finding)
|
||||||
@@ -49,6 +53,10 @@ module Ops
|
|||||||
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
|
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve_kind(kind)
|
||||||
|
Ops::Incident.open.where(kind: kind).find_each(&:resolve!)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fingerprint_for(finding)
|
def fingerprint_for(finding)
|
||||||
|
|||||||
@@ -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
|
module Recordings
|
||||||
class FinalizeSession
|
class FinalizeSession
|
||||||
def initialize(session)
|
def initialize(session)
|
||||||
@@ -5,29 +7,69 @@ module Recordings
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
team = @session.match.team
|
policy = Recordings::StoragePolicy.call(@session)
|
||||||
return unless team.entitlements.can_create_recordings?
|
return if policy == Recordings::StoragePolicy::NONE
|
||||||
|
|
||||||
retention_days = team.entitlements.recording_retention_days
|
team = @session.match.team
|
||||||
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
|
attrs = attributes_for(policy, team)
|
||||||
|
|
||||||
recording = Recording.find_or_initialize_by(stream_session: @session)
|
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,
|
team: team,
|
||||||
status: "processing",
|
status: "processing",
|
||||||
title: default_title,
|
title: default_title,
|
||||||
privacy_status: privacy_from_session,
|
privacy_status: privacy_from_session,
|
||||||
storage_path: @session.mediamtx_path_name,
|
storage_path: @session.mediamtx_path_name,
|
||||||
recorded_at: @session.ended_at || Time.current,
|
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,
|
error_message: nil,
|
||||||
metadata: initial_metadata(team)
|
metadata: initial_metadata(policy, team)
|
||||||
)
|
}
|
||||||
recording.save!
|
|
||||||
recording
|
|
||||||
end
|
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
|
def default_title
|
||||||
match = @session.match
|
match = @session.match
|
||||||
@@ -38,12 +80,13 @@ module Recordings
|
|||||||
@session.privacy_status == "public" ? "public" : "unlisted"
|
@session.privacy_status == "public" ? "public" : "unlisted"
|
||||||
end
|
end
|
||||||
|
|
||||||
def initial_metadata(team)
|
def initial_metadata(policy, team)
|
||||||
ent = team.entitlements
|
|
||||||
{
|
{
|
||||||
"source_platform" => @session.platform,
|
"source_platform" => @session.platform,
|
||||||
"session_privacy" => @session.privacy_status,
|
"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" => {}
|
"ai" => {}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ module Recordings
|
|||||||
private
|
private
|
||||||
|
|
||||||
def deliver_expiring_soon(user)
|
def deliver_expiring_soon(user)
|
||||||
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now
|
MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user))
|
||||||
rescue EOFError => e
|
rescue EOFError => e
|
||||||
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
|
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ module Recordings
|
|||||||
private
|
private
|
||||||
|
|
||||||
def deliver_replay_ready(user)
|
def deliver_replay_ready(user)
|
||||||
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now
|
MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user))
|
||||||
rescue EOFError => e
|
rescue EOFError => e
|
||||||
# Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita.
|
# Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita.
|
||||||
Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}")
|
Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}")
|
||||||
|
|||||||
@@ -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
|
def call
|
||||||
recording = Recording.find_by(stream_session: @session)
|
recording = Recording.find_by(stream_session: @session)
|
||||||
return unless recording&.status == "processing"
|
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?
|
if source_files.empty?
|
||||||
fail_recording!(recording, "Nessun file di registrazione trovato")
|
fail_recording!(recording, "Nessun file di registrazione trovato")
|
||||||
cleanup_mediamtx_path
|
cleanup_mediamtx_path
|
||||||
@@ -35,23 +36,52 @@ module Recordings
|
|||||||
error_message: nil
|
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_local_sources(source_files, merged_path)
|
||||||
|
cleanup_cloud_pull!
|
||||||
|
cleanup_remote_recordings!
|
||||||
cleanup_mediamtx_path
|
cleanup_mediamtx_path
|
||||||
Recordings::PostProcessJob.perform_async(recording.id)
|
Recordings::PostProcessJob.perform_async(recording.id)
|
||||||
recording
|
recording
|
||||||
rescue Error, Recordings::Storage::Error => e
|
rescue Error, Recordings::Storage::Error, Recordings::PullFromCloudNode::Error => e
|
||||||
recording = Recording.find_by(stream_session: @session)
|
recording = Recording.find_by(stream_session: @session)
|
||||||
fail_recording!(recording, e.message) if recording
|
fail_recording!(recording, e.message) if recording
|
||||||
raise
|
raise
|
||||||
ensure
|
ensure
|
||||||
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
|
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
|
||||||
|
cleanup_cloud_pull! unless recording_ready?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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
|
def local_source_files
|
||||||
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
|
scan_recording_files(File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name))
|
||||||
return [] unless Dir.exist?(base)
|
end
|
||||||
|
|
||||||
|
def scan_recording_files(base)
|
||||||
|
return [] if base.blank? || !Dir.exist?(base)
|
||||||
|
|
||||||
Dir.glob(File.join(base, "**", "*"))
|
Dir.glob(File.join(base, "**", "*"))
|
||||||
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
|
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
|
||||||
@@ -83,7 +113,11 @@ module Recordings
|
|||||||
end
|
end
|
||||||
|
|
||||||
def object_key(recording)
|
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
|
end
|
||||||
|
|
||||||
def cleanup_local_sources(source_files, merged_path)
|
def cleanup_local_sources(source_files, merged_path)
|
||||||
@@ -100,6 +134,24 @@ module Recordings
|
|||||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||||
end
|
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)
|
def fail_recording!(recording, message)
|
||||||
recording.update!(status: "failed", error_message: message)
|
recording.update!(status: "failed", error_message: message)
|
||||||
cleanup_mediamtx_path
|
cleanup_mediamtx_path
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
info = enable_embed_if_needed!(info)
|
||||||
|
apply_verified!(info)
|
||||||
|
Rails.logger.info(
|
||||||
|
"[Recordings::VerifyYoutubeReplay] ok recording=#{@recording.id} " \
|
||||||
|
"youtube_video_id=#{info.video_id} embeddable=#{info.embeddable}"
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
"embeddable" => info.embeddable,
|
||||||
|
"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
|
||||||
|
|
||||||
|
def enable_embed_if_needed!(info)
|
||||||
|
return info if info.embeddable != false
|
||||||
|
return info if info.video_id.to_s.start_with?("mock_")
|
||||||
|
|
||||||
|
session = @recording.stream_session
|
||||||
|
Youtube::BroadcastService.new(session.match.team).enable_video_embed!(
|
||||||
|
info.video_id,
|
||||||
|
privacy_status: info.privacy_status
|
||||||
|
)
|
||||||
|
refreshed = Youtube::VodStatus.new(@recording.team, channel: "team").fetch(info.video_id)
|
||||||
|
refreshed.ready ? refreshed : info
|
||||||
|
rescue Youtube::BroadcastService::Error, Youtube::VodStatus::Error => e
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[Recordings::VerifyYoutubeReplay] enable_embed recording=#{@recording.id}: #{e.message}"
|
||||||
|
)
|
||||||
|
info
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sessions
|
||||||
|
# Normalizza e applica fingerprint del client (OS, app, device, operatore)
|
||||||
|
# sulla sessione, a create e/o a ogni telemetry.
|
||||||
|
class ApplyClientInfo
|
||||||
|
OS_VALUES = %w[android ios].freeze
|
||||||
|
MAX_LEN = 80
|
||||||
|
|
||||||
|
ATTRS = %i[
|
||||||
|
client_os app_version app_build device_manufacturer device_model os_version carrier
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def self.call(session, raw)
|
||||||
|
new(session, raw).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(session, raw)
|
||||||
|
@session = session
|
||||||
|
@raw = normalize_hash(raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
attrs = extract_attrs
|
||||||
|
return @session if attrs.empty?
|
||||||
|
|
||||||
|
@session.assign_attributes(attrs)
|
||||||
|
@session.save! if @session.persisted? && @session.changed?
|
||||||
|
@session
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_hash(raw)
|
||||||
|
return {} if raw.blank?
|
||||||
|
|
||||||
|
data = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
|
||||||
|
data = data.to_h if data.respond_to?(:to_h)
|
||||||
|
data.with_indifferent_access
|
||||||
|
rescue StandardError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_attrs
|
||||||
|
attrs = {}
|
||||||
|
|
||||||
|
os = @raw[:os].presence || @raw[:client_os].presence
|
||||||
|
os = os.to_s.downcase.strip
|
||||||
|
attrs[:client_os] = os if OS_VALUES.include?(os)
|
||||||
|
|
||||||
|
{
|
||||||
|
app_version: %i[app_version version],
|
||||||
|
app_build: %i[app_build build build_number],
|
||||||
|
device_manufacturer: %i[device_manufacturer manufacturer],
|
||||||
|
device_model: %i[device_model model],
|
||||||
|
os_version: %i[os_version system_version],
|
||||||
|
carrier: %i[carrier network_operator operator]
|
||||||
|
}.each do |column, keys|
|
||||||
|
value = keys.map { |k| @raw[k] }.find(&:present?)
|
||||||
|
next if value.blank?
|
||||||
|
|
||||||
|
attrs[column] = truncate(value.to_s.strip)
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
def truncate(value)
|
||||||
|
value.bytesize <= MAX_LEN ? value : value.byteslice(0, MAX_LEN)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -26,6 +26,7 @@ module Sessions
|
|||||||
target_fps: @params[:target_fps] || 30,
|
target_fps: @params[:target_fps] || 30,
|
||||||
status: "idle"
|
status: "idle"
|
||||||
)
|
)
|
||||||
|
Sessions::ApplyClientInfo.call(session, @params[:client])
|
||||||
|
|
||||||
youtube_channel = nil
|
youtube_channel = nil
|
||||||
if session.platform == "youtube"
|
if session.platform == "youtube"
|
||||||
@@ -46,18 +47,23 @@ module Sessions
|
|||||||
{
|
{
|
||||||
created: true,
|
created: true,
|
||||||
platform: session.platform,
|
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
|
end
|
||||||
|
|
||||||
|
kick_autoscaler_if_soft_limit!
|
||||||
|
|
||||||
if session.platform == "youtube"
|
if session.platform == "youtube"
|
||||||
YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel)
|
YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel)
|
||||||
end
|
end
|
||||||
|
|
||||||
session
|
session
|
||||||
rescue Streams::NodeRegistry::NoCapacityError => e
|
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
|
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
||||||
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
|
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
|
||||||
rescue Mediamtx::Client::Error => e
|
rescue Mediamtx::Client::Error => e
|
||||||
@@ -69,6 +75,28 @@ module Sessions
|
|||||||
|
|
||||||
private
|
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)
|
def assert_youtube_channel!(youtube_channel)
|
||||||
resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel)
|
resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel)
|
||||||
if resolver.resolve.blank?
|
if resolver.resolve.blank?
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ module Sessions
|
|||||||
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
||||||
|
|
||||||
recording = Recordings::FinalizeSession.new(@session).call
|
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!
|
remove_mediamtx_paths!
|
||||||
|
|
||||||
log_event("ended")
|
log_event("ended")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ module Users
|
|||||||
return if user.nil?
|
return if user.nil?
|
||||||
|
|
||||||
token = user.generate_password_reset!
|
token = user.generate_password_reset!
|
||||||
UserMailer.password_reset(user, token).deliver_now
|
MatchLiveTv.deliver_mail(UserMailer.password_reset(user, token))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -97,6 +97,24 @@ module Youtube
|
|||||||
:skipped
|
:skipped
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enable_video_embed!(video_id, privacy_status: "unlisted")
|
||||||
|
return if video_id.blank? || video_id.to_s.start_with?("mock_")
|
||||||
|
return if @credential.blank? || missing_oauth_config?
|
||||||
|
|
||||||
|
client = authorized_client
|
||||||
|
video = Google::Apis::YoutubeV3::Video.new(
|
||||||
|
id: video_id,
|
||||||
|
status: Google::Apis::YoutubeV3::VideoStatus.new(
|
||||||
|
embeddable: true,
|
||||||
|
privacy_status: privacy_status.presence || "unlisted",
|
||||||
|
self_declared_made_for_kids: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.update_video("status", video)
|
||||||
|
rescue Google::Apis::Error => e
|
||||||
|
raise Error, e.message
|
||||||
|
end
|
||||||
|
|
||||||
def complete_broadcast!(broadcast_id)
|
def complete_broadcast!(broadcast_id)
|
||||||
return if broadcast_id.blank?
|
return if broadcast_id.blank?
|
||||||
return if @credential.blank? || missing_oauth_config?
|
return if @credential.blank? || missing_oauth_config?
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# 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,
|
||||||
|
:embeddable,
|
||||||
|
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",
|
||||||
|
embeddable: true
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
embeddable: item.status&.embeddable != false
|
||||||
|
)
|
||||||
|
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
|
||||||
@@ -5,6 +5,32 @@
|
|||||||
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
|
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="panel admin-analytics-preview">
|
||||||
|
<h3><%= t("admin.analytics.preview.title") %></h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.analytics.preview.lead") %></p>
|
||||||
|
<% if @analytics_preview_active %>
|
||||||
|
<p class="admin-analytics-preview__status"><%= t("admin.analytics.preview.active") %></p>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= button_to t("admin.analytics.preview.disable"),
|
||||||
|
admin_analytics_preview_path,
|
||||||
|
method: :delete,
|
||||||
|
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.analytics.preview.open_site"),
|
||||||
|
root_path,
|
||||||
|
class: "admin-btn admin-btn--primary admin-btn--sm",
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener" %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= button_to t("admin.analytics.preview.enable"),
|
||||||
|
admin_analytics_preview_path(return_to: root_path),
|
||||||
|
method: :post,
|
||||||
|
class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="panel admin-sessions-filters">
|
<div class="panel admin-sessions-filters">
|
||||||
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
|
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
<div class="admin-filter-grid">
|
<div class="admin-filter-grid">
|
||||||
@@ -58,8 +84,9 @@
|
|||||||
<td class="muted"><%= avg %>%</td>
|
<td class="muted"><%= avg %>%</td>
|
||||||
<td class="muted"><%= row[:max_scroll] %>%</td>
|
<td class="muted"><%= row[:max_scroll] %>%</td>
|
||||||
<td>
|
<td>
|
||||||
<%= link_to t("admin.analytics.index.heatmap"),
|
<% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
|
||||||
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
<% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
|
||||||
|
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="admin-page-head admin-session-head">
|
<div class="admin-page-head admin-session-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="muted admin-page-sub">
|
<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]) %>
|
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to]) %>
|
||||||
</p>
|
</p>
|
||||||
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
|
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
|
||||||
<p><code class="admin-mono"><%= @page_path %></code></p>
|
<p><code class="admin-mono"><%= @page_path %></code></p>
|
||||||
@@ -11,8 +11,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel admin-sessions-filters">
|
<div class="panel admin-sessions-filters">
|
||||||
|
<p class="admin-filter-field" style="margin-bottom: 0.75rem;">
|
||||||
|
<span><%= t("admin.analytics.show.device_tabs_label") %></span>
|
||||||
|
</p>
|
||||||
|
<div class="admin-locale-tabs" role="tablist">
|
||||||
|
<% AnalyticsEvent::DEVICES.each do |device| %>
|
||||||
|
<% stats = @device_tab_stats[device] %>
|
||||||
|
<% active = @filters[:device] == device %>
|
||||||
|
<%= link_to admin_analytics_page_path(
|
||||||
|
page_path: @page_path,
|
||||||
|
from: @filters[:from],
|
||||||
|
to: @filters[:to],
|
||||||
|
device: device,
|
||||||
|
layer: @filters[:layer]
|
||||||
|
),
|
||||||
|
class: "admin-locale-tab #{'is-active' if active}",
|
||||||
|
role: "tab",
|
||||||
|
"aria-selected": active do %>
|
||||||
|
<%= t("admin.analytics.devices.#{device}") %>
|
||||||
|
<span class="admin-device-tab-count"><%= stats[:pageviews] %></span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
|
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
<%= hidden_field_tag :page_path, @page_path %>
|
<%= hidden_field_tag :page_path, @page_path %>
|
||||||
|
<%= hidden_field_tag :device, @filters[:device] %>
|
||||||
<div class="admin-filter-grid">
|
<div class="admin-filter-grid">
|
||||||
<label class="admin-filter-field">
|
<label class="admin-filter-field">
|
||||||
<span><%= t("admin.analytics.filters.from") %></span>
|
<span><%= t("admin.analytics.filters.from") %></span>
|
||||||
@@ -22,14 +46,6 @@
|
|||||||
<span><%= t("admin.analytics.filters.to") %></span>
|
<span><%= t("admin.analytics.filters.to") %></span>
|
||||||
<%= date_field_tag :to, @filters[:to] %>
|
<%= date_field_tag :to, @filters[:to] %>
|
||||||
</label>
|
</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">
|
<label class="admin-filter-field">
|
||||||
<span><%= t("admin.analytics.filters.layer") %></span>
|
<span><%= t("admin.analytics.filters.layer") %></span>
|
||||||
<%= select_tag :layer,
|
<%= select_tag :layer,
|
||||||
@@ -91,7 +107,12 @@
|
|||||||
<h3>
|
<h3>
|
||||||
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
|
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p>
|
<p class="muted admin-table-sub">
|
||||||
|
<%= t(
|
||||||
|
"admin.analytics.show.heatmap_hint",
|
||||||
|
device: t("admin.analytics.devices.#{@filters[:device]}")
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
|
||||||
<% if @cells.any? %>
|
<% if @cells.any? %>
|
||||||
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
||||||
@@ -115,13 +136,20 @@
|
|||||||
></canvas>
|
></canvas>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
|
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
|
||||||
<%= t("admin.analytics.show.snapshot_meta",
|
<%= t(
|
||||||
device: @snapshot.device,
|
"admin.analytics.show.snapshot_meta",
|
||||||
at: l(@snapshot.captured_at, format: :short)) %>
|
device: t("admin.analytics.devices.#{@snapshot.device}"),
|
||||||
|
at: l(@snapshot.captured_at, format: :short)
|
||||||
|
) %>
|
||||||
</p>
|
</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
||||||
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
|
<p class="muted admin-heatmap-fallback-note">
|
||||||
|
<%= t(
|
||||||
|
"admin.analytics.show.snapshot_missing",
|
||||||
|
device: t("admin.analytics.devices.#{@filters[:device]}")
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
<canvas
|
<canvas
|
||||||
id="admin-heatmap"
|
id="admin-heatmap"
|
||||||
class="admin-heatmap-overlay"
|
class="admin-heatmap-overlay"
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<%= form_with model: @entry, url: url, method: method, local: true, class: "admin-filter-form" do |f| %>
|
||||||
|
<div class="admin-filter-grid">
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.costs.entries.month") %></span>
|
||||||
|
<%= text_field_tag "platform_cost_entry[month]",
|
||||||
|
@entry.month&.strftime("%Y-%m"),
|
||||||
|
type: "month",
|
||||||
|
required: true %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.costs.entries.label") %></span>
|
||||||
|
<%= f.text_field :label, required: true, maxlength: 120 %>
|
||||||
|
<span class="muted admin-table-sub"><%= t("admin.costs.entries.label_hint") %></span>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.costs.entries.amount") %></span>
|
||||||
|
<%= number_field_tag "platform_cost_entry[amount_euros]",
|
||||||
|
(@entry.amount_cents.to_f / 100.0 if @entry.amount_cents.present?),
|
||||||
|
step: 0.01,
|
||||||
|
min: 0.01,
|
||||||
|
required: true %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field" style="grid-column: span 2;">
|
||||||
|
<span><%= t("admin.costs.entries.notes") %></span>
|
||||||
|
<%= f.text_area :notes, rows: 3 %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= f.submit t("admin.costs.entries.save"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.costs.entries.cancel"),
|
||||||
|
admin_costs_path(month: @entry.month&.strftime("%Y-%m")),
|
||||||
|
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.costs.entries.edit_title") %></h2>
|
||||||
|
<p class="muted admin-page-sub"><%= @entry.label %> · <%= admin_month_label(@entry.month) %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<%= render "form", url: admin_cost_entry_path(@entry), method: :patch %>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.costs.entries.new_title") %></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<%= render "form", url: admin_cost_entries_path, method: :post %>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
<% content_for :head do %>
|
||||||
|
<script>
|
||||||
|
window.adminCostTrend = <%= raw @trend.to_json %>;
|
||||||
|
window.adminCostI18n = {
|
||||||
|
cost: <%= raw t("admin.costs.charts.cost_revenue").to_json %>,
|
||||||
|
revenue: <%= raw t("admin.costs.kpi.revenue").to_json %>,
|
||||||
|
margin: <%= raw t("admin.costs.kpi.margin").to_json %>,
|
||||||
|
costPerHour: <%= raw t("admin.costs.charts.cost_per_hour").to_json %>,
|
||||||
|
hours: <%= raw t("admin.costs.charts.hours").to_json %>
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="/admin-costs.js?v=1" defer></script>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.costs.index.title") %></h2>
|
||||||
|
<p class="muted admin-page-sub"><%= t("admin.costs.index.lead") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel admin-sessions-filters">
|
||||||
|
<%= form_with url: admin_costs_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
|
<div class="admin-filter-grid">
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.costs.index.month") %></span>
|
||||||
|
<%= select_tag :month,
|
||||||
|
options_for_select(
|
||||||
|
@month_options.map { |m| [admin_month_label(m), m.strftime("%Y-%m")] },
|
||||||
|
@month.strftime("%Y-%m")
|
||||||
|
) %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= submit_tag t("admin.costs.index.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.costs.index.add_entry"),
|
||||||
|
new_admin_cost_entry_path(month: @month.strftime("%Y-%m")),
|
||||||
|
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="kpi-grid">
|
||||||
|
<div class="kpi-card kpi-card--accent">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.platform_cost") %></div>
|
||||||
|
<div class="kpi-value"><%= format_euros(@summary[:cost_cents]) %></div>
|
||||||
|
<div class="kpi-sub"><%= admin_month_label(@month) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.revenue") %></div>
|
||||||
|
<div class="kpi-value"><%= format_euros(@summary[:revenue_cents]) %></div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
<%= t("admin.costs.kpi.margin") %>: <%= format_euros(@summary[:margin_cents]) %>
|
||||||
|
<% if @summary[:margin_pct] %>
|
||||||
|
· <%= @summary[:margin_pct] %>%
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_hour") %></div>
|
||||||
|
<div class="kpi-value"><%= format_euros(@summary[:cost_per_hour_cents]) %></div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
<%= t("admin.costs.kpi.revenue_per_hour") %>: <%= format_euros(@summary[:revenue_per_hour_cents]) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.hours") %></div>
|
||||||
|
<div class="kpi-value"><%= format_hours(@summary[:hours]) %></div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
<%= t("admin.costs.kpi.sessions") %>: <%= @summary[:sessions] %>
|
||||||
|
· <%= t("admin.costs.kpi.clubs_active") %>: <%= @summary[:clubs_active] %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_session") %></div>
|
||||||
|
<div class="kpi-value"><%= format_euros(@summary[:cost_per_session_cents]) %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.costs.kpi.cost_per_club") %>: <%= format_euros(@summary[:cost_per_club_cents]) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.storage") %></div>
|
||||||
|
<div class="kpi-value" style="font-size:1.2rem"><%= format_bytes(@summary[:storage_bytes]) %></div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
<%= t("admin.costs.kpi.cost_per_gb") %>: <%= format_euros(@summary[:cost_per_gb_cents]) %>
|
||||||
|
· <%= t("admin.costs.kpi.storage_note") %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="margin-bottom:1.25rem">
|
||||||
|
<h3><%= t("admin.costs.index.trends_title") %></h3>
|
||||||
|
<div class="charts-grid">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3><%= t("admin.costs.charts.cost_revenue") %></h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-cost-revenue"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3><%= t("admin.costs.charts.cost_per_hour") %></h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-cost-hour"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3><%= t("admin.costs.charts.hours") %></h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-hours"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="margin-bottom:1.25rem">
|
||||||
|
<h3><%= t("admin.costs.index.entries_title") %></h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.costs.index.entries_lead") %></p>
|
||||||
|
<% if @entries.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.costs.table.label") %></th>
|
||||||
|
<th><%= t("admin.costs.table.amount") %></th>
|
||||||
|
<th><%= t("admin.costs.table.notes") %></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @entries.each do |entry| %>
|
||||||
|
<tr>
|
||||||
|
<td><strong><%= entry.label %></strong></td>
|
||||||
|
<td><%= format_euros(entry.amount_cents) %></td>
|
||||||
|
<td class="muted"><%= entry.notes.presence || t("admin.common.dash") %></td>
|
||||||
|
<td class="admin-actions">
|
||||||
|
<%= link_to t("admin.announcements.actions.edit"),
|
||||||
|
edit_admin_cost_entry_path(entry),
|
||||||
|
class: "admin-btn admin-btn--sm" %>
|
||||||
|
<%= button_to t("admin.costs.entries.delete"),
|
||||||
|
admin_cost_entry_path(entry),
|
||||||
|
method: :delete,
|
||||||
|
class: "admin-btn admin-btn--sm admin-btn--danger",
|
||||||
|
form: { data: { confirm: t("admin.costs.entries.delete_confirm") } } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.costs.kpi.platform_cost") %></th>
|
||||||
|
<th><%= format_euros(@summary[:cost_cents]) %></th>
|
||||||
|
<th colspan="2"></th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.costs.index.no_entries") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.costs.index.clubs_title") %></h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.costs.index.clubs_lead") %></p>
|
||||||
|
<% if @clubs.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.costs.table.club") %></th>
|
||||||
|
<th><%= t("admin.costs.table.plan") %></th>
|
||||||
|
<th><%= t("admin.costs.table.hours") %></th>
|
||||||
|
<th><%= t("admin.costs.table.hours_share") %></th>
|
||||||
|
<th><%= t("admin.costs.table.allocated_cost") %></th>
|
||||||
|
<th><%= t("admin.costs.table.revenue") %></th>
|
||||||
|
<th><%= t("admin.costs.table.margin") %></th>
|
||||||
|
<th><%= t("admin.costs.table.cost_per_hour") %></th>
|
||||||
|
<th><%= t("admin.costs.table.cost_per_session") %></th>
|
||||||
|
<th><%= t("admin.costs.table.storage") %></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @clubs.each do |row| %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<%= link_to row[:club_name], admin_club_path(row[:club_id]), class: "admin-table-strong" %>
|
||||||
|
</td>
|
||||||
|
<td class="muted"><%= row[:plan_slug] || t("admin.common.dash") %></td>
|
||||||
|
<td><%= format_hours(row[:hours]) %></td>
|
||||||
|
<td class="muted"><%= row[:hours_share_pct] %>%</td>
|
||||||
|
<td><%= format_euros(row[:allocated_cost_cents]) %></td>
|
||||||
|
<td><%= format_euros(row[:revenue_cents]) %></td>
|
||||||
|
<td><%= format_euros(row[:margin_cents]) %></td>
|
||||||
|
<td><%= format_euros(row[:cost_per_hour_cents]) %></td>
|
||||||
|
<td><%= format_euros(row[:cost_per_session_cents]) %></td>
|
||||||
|
<td class="muted"><%= format_bytes(row[:storage_bytes]) %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.costs.index.no_clubs") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
@@ -107,6 +107,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th><%= t("admin.dashboard.sessions.table.match") %></th>
|
<th><%= t("admin.dashboard.sessions.table.match") %></th>
|
||||||
<th><%= t("admin.dashboard.sessions.table.status") %></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.ingest") %></th>
|
||||||
<th><%= t("admin.dashboard.sessions.table.start") %></th>
|
<th><%= t("admin.dashboard.sessions.table.start") %></th>
|
||||||
<th><%= t("admin.dashboard.sessions.table.link") %></th>
|
<th><%= t("admin.dashboard.sessions.table.link") %></th>
|
||||||
@@ -118,6 +119,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
<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><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><%= 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 class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<% node = session.stream_node %>
|
<% slug = session.ingest_slug_display %>
|
||||||
<% if node %>
|
<% role = session.ingest_role_display %>
|
||||||
|
<% if slug.present? %>
|
||||||
<div class="admin-ingest">
|
<div class="admin-ingest">
|
||||||
<code class="admin-ingest__slug"><%= node.slug %></code>
|
<code class="admin-ingest__slug"><%= slug %></code>
|
||||||
<span class="badge <%= admin_session_ingest_badge_class(node) %>"><%= admin_session_ingest_role_label(node) %></span>
|
<% if role.present? %>
|
||||||
|
<span class="badge <%= admin_session_ingest_badge_class(role) %>"><%= admin_session_ingest_role_label(role) %></span>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
|
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<span><%= t("admin.sessions.index.filters.platform") %></span>
|
<span><%= t("admin.sessions.index.filters.platform") %></span>
|
||||||
<%= select_tag :platform,
|
<%= select_tag :platform,
|
||||||
options_for_select(
|
options_for_select(
|
||||||
[[t("admin.sessions.index.filters.any"), ""]] + StreamSession::PLATFORMS.map { |p| [p, p] },
|
[[t("admin.sessions.index.filters.any"), ""]] + StreamSession::LIVE_PLATFORMS.map { |p| [p, p] },
|
||||||
@filters[:platform]
|
@filters[:platform]
|
||||||
) %>
|
) %>
|
||||||
</label>
|
</label>
|
||||||
@@ -83,6 +83,7 @@
|
|||||||
<th><%= t("admin.sessions.index.table.ended") %></th>
|
<th><%= t("admin.sessions.index.table.ended") %></th>
|
||||||
<th><%= t("admin.sessions.index.table.duration") %></th>
|
<th><%= t("admin.sessions.index.table.duration") %></th>
|
||||||
<th><%= t("admin.sessions.index.table.ingest") %></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.disconnects") %></th>
|
||||||
<th><%= t("admin.sessions.index.table.link") %></th>
|
<th><%= t("admin.sessions.index.table.link") %></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
<td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></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 class="muted"><%= admin_session_duration_label(s) %></td>
|
||||||
<td><%= render "admin/sessions/ingest_cell", session: 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><%= s.disconnection_count %></td>
|
||||||
<td>
|
<td>
|
||||||
<div class="admin-link-compact">
|
<div class="admin-link-compact">
|
||||||
|
|||||||
@@ -67,6 +67,38 @@
|
|||||||
<dt><%= t("admin.sessions.show.fields.platform") %></dt>
|
<dt><%= t("admin.sessions.show.fields.platform") %></dt>
|
||||||
<dd><%= @session.platform %></dd>
|
<dd><%= @session.platform %></dd>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<dt><%= t("admin.sessions.show.fields.privacy") %></dt>
|
<dt><%= t("admin.sessions.show.fields.privacy") %></dt>
|
||||||
<dd><%= @session.privacy_status %></dd>
|
<dd><%= @session.privacy_status %></dd>
|
||||||
@@ -130,12 +162,20 @@
|
|||||||
<div>
|
<div>
|
||||||
<dt><%= t("admin.sessions.show.fields.node") %></dt>
|
<dt><%= t("admin.sessions.show.fields.node") %></dt>
|
||||||
<dd>
|
<dd>
|
||||||
<% if @session.stream_node %>
|
<% slug = @session.ingest_slug_display %>
|
||||||
<code><%= @session.stream_node.slug %></code>
|
<% role = @session.ingest_role_display %>
|
||||||
<span class="badge <%= admin_session_ingest_badge_class(@session.stream_node) %>">
|
<% if slug.present? %>
|
||||||
<%= admin_session_ingest_role_label(@session.stream_node) %>
|
<code><%= slug %></code>
|
||||||
</span>
|
<% if role.present? %>
|
||||||
<span class="muted">(<%= @session.stream_node.provider %>)</span>
|
<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 %>
|
<% else %>
|
||||||
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
|
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<link rel="stylesheet" href="/admin.css?v=15">
|
<link rel="stylesheet" href="/admin.css?v=15">
|
||||||
|
<%= yield :head %>
|
||||||
<% if content_for?(:replay_archive_styles) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
<%= 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.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.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
|
||||||
|
<%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %>
|
||||||
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
<%= 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 %>
|
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
||||||
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
<meta name="application-name" content="Match Live TV">
|
<meta name="application-name" content="Match Live TV">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
|
<%= render "shared/analytics_suppress" %>
|
||||||
<%= yield :head %>
|
<%= 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="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=77">
|
||||||
</head>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<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" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/site-analytics.js?v=4" defer></script>
|
<script src="/site-analytics.js?v=5" defer></script>
|
||||||
<script src="/cookie-consent.js?v=2" defer></script>
|
<script src="/cookie-consent.js?v=3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
|
<%= render "shared/analytics_suppress" %>
|
||||||
<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="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=77">
|
||||||
<link rel="stylesheet" href="/live.css?v=26">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
<%= render "shared/marketing_footer" %>
|
<%= render "shared/marketing_footer" %>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/site-analytics.js?v=4" defer></script>
|
<script src="/site-analytics.js?v=5" defer></script>
|
||||||
<script src="/cookie-consent.js?v=2" defer></script>
|
<script src="/cookie-consent.js?v=3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -63,6 +63,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="section wrap" aria-labelledby="contacts-form-title">
|
||||||
<div class="contacts-form-panel">
|
<div class="contacts-form-panel">
|
||||||
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
|
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
|
||||||
|
|||||||
@@ -43,9 +43,9 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<% ent = @recording.team.entitlements %>
|
<% 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">
|
<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" %>
|
<%= link_to t("replay.show.download_link"), public_replay_download_path(@session), class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if @recording.youtube_watch_url %>
|
<% if @recording.youtube_watch_url %>
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% elsif @recording.ready? && @recording.youtube_watch_url.present? %>
|
<% 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">
|
<div class="live-player-wrap live-player-wrap--embed">
|
||||||
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
|
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
|
||||||
title="<%= @recording.title_or_default %>"
|
title="<%= @recording.title_or_default %>"
|
||||||
@@ -68,6 +68,14 @@
|
|||||||
badge_label: t("replay.show.badge_label"),
|
badge_label: t("replay.show.badge_label"),
|
||||||
badge_class: "badge-ended" %>
|
badge_class: "badge-ended" %>
|
||||||
</div>
|
</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">
|
<p class="replay-show__meta-line">
|
||||||
<%= t("replay.show.youtube_only_body") %>
|
<%= t("replay.show.youtube_only_body") %>
|
||||||
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
|
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
|
||||||
|
|||||||
@@ -158,16 +158,16 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<span class="visually-hidden"><%= privacy_label %></span>
|
<span class="visually-hidden"><%= privacy_label %></span>
|
||||||
<% end %>
|
<% 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") %>
|
<%= 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 %>
|
<% 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" } %>
|
<%= 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 %>
|
<% 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") %>
|
<%= 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 %>
|
<% end %>
|
||||||
<% has_youtube = rec.youtube_video_id.present? && !rec.youtube_video_id.to_s.start_with?("mock_") %>
|
<% 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") %>
|
<% delete_confirm = t("recordings.archive.delete_confirm") %>
|
||||||
<%= button_to paths.destroy.call(rec), method: :delete,
|
<%= button_to paths.destroy.call(rec), method: :delete,
|
||||||
params: filter_params,
|
params: filter_params,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<% if Analytics::Suppress.active?(request.cookie_jar[Analytics::Suppress::COOKIE_NAME]) %>
|
||||||
|
<meta name="mltv-analytics-suppress" content="1">
|
||||||
|
<div class="mltv-preview-badge" role="status">
|
||||||
|
<span><%= t("ui.analytics_preview.badge") %></span>
|
||||||
|
<%= link_to t("ui.analytics_preview.manage"), admin_analytics_path, class: "mltv-preview-badge__link" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div>
|
<div class="site-footer__brand">
|
||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
<%= render "shared/store_badges", variant: "footer" %>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="site-footer__nav">
|
||||||
<%= link_to t("common.contacts"), public_contatti_path %> ·
|
<%= link_to t("common.contacts"), public_contatti_path %> ·
|
||||||
<%= link_to t("common.support"), public_support_path %> ·
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||||
@@ -15,6 +14,10 @@
|
|||||||
<%= link_to t("common.terms"), public_termini_path %>
|
<%= link_to t("common.terms"), public_termini_path %>
|
||||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="site-footer__apps">
|
||||||
|
<%= render "shared/store_badges", variant: "footer" %>
|
||||||
|
<%= render "shared/social_links", modifier: "footer" %>
|
||||||
|
</div>
|
||||||
<div class="site-footer__legal">
|
<div class="site-footer__legal">
|
||||||
<p><%= t("footer.copyright") %></p>
|
<p><%= t("footer.copyright") %></p>
|
||||||
<p><%= t("footer.responsibility") %></p>
|
<p><%= t("footer.responsibility") %></p>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div>
|
<div class="site-footer__brand">
|
||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="site-footer__nav">
|
||||||
<%= link_to t("common.support"), public_support_path %> ·
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||||
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||||
<%= link_to t("common.terms"), public_termini_path %>
|
<%= link_to t("common.terms"), public_termini_path %>
|
||||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="site-footer__apps">
|
||||||
|
<%= render "shared/social_links", modifier: "footer" %>
|
||||||
|
</div>
|
||||||
<div class="site-footer__legal">
|
<div class="site-footer__legal">
|
||||||
<p><%= t("footer.copyright") %></p>
|
<p><%= t("footer.copyright") %></p>
|
||||||
<p><%= t("footer.responsibility") %></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>
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
require "net/smtp"
|
||||||
|
require "openssl"
|
||||||
|
|
||||||
module MatchLiveTv
|
module MatchLiveTv
|
||||||
class << self
|
class << self
|
||||||
def jwt_secret
|
def jwt_secret
|
||||||
@@ -102,6 +105,7 @@ module MatchLiveTv
|
|||||||
end
|
end
|
||||||
|
|
||||||
# In produzione senza SMTP non blocca il flusso (es. collaudo): la mail si può inviare a mano.
|
# In produzione senza SMTP non blocca il flusso (es. collaudo): la mail si può inviare a mano.
|
||||||
|
# Errori SMTP (dominio invalido, EOF Aruba, porta chiusa) non devono far crashare la request.
|
||||||
def deliver_mail(mail)
|
def deliver_mail(mail)
|
||||||
if Rails.env.production? && !smtp_configured?
|
if Rails.env.production? && !smtp_configured?
|
||||||
Rails.logger.info("[mail] skip (SMTP assente): #{mail.subject}")
|
Rails.logger.info("[mail] skip (SMTP assente): #{mail.subject}")
|
||||||
@@ -110,6 +114,9 @@ module MatchLiveTv
|
|||||||
|
|
||||||
mail.deliver_now
|
mail.deliver_now
|
||||||
true
|
true
|
||||||
|
rescue EOFError, Net::SMTPError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, OpenSSL::SSL::SSLError => e
|
||||||
|
Rails.logger.warn("[mail] delivery failed: #{mail.subject} #{e.class}: #{e.message}")
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_name
|
def privacy_controller_name
|
||||||
@@ -196,6 +203,24 @@ module MatchLiveTv
|
|||||||
ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true"
|
ENV.fetch("REPLAY_MEDIA_REDIRECT", "true") == "true"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Retention copia temporanea post-live YouTube (ore). Clamp 24–72, default 48.
|
||||||
|
def youtube_temp_replay_retention_hours
|
||||||
|
raw = ENV.fetch("YOUTUBE_TEMP_REPLAY_RETENTION_HOURS", "48").to_i
|
||||||
|
raw.clamp(24, 72)
|
||||||
|
end
|
||||||
|
|
||||||
|
def youtube_replay_verify_max_attempts
|
||||||
|
ENV.fetch("YOUTUBE_REPLAY_VERIFY_MAX_ATTEMPTS", "12").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def youtube_replay_verify_base_interval_secs
|
||||||
|
ENV.fetch("YOUTUBE_REPLAY_VERIFY_BASE_INTERVAL_SECS", "300").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def youtube_replay_verify_grace_secs
|
||||||
|
ENV.fetch("YOUTUBE_REPLAY_VERIFY_GRACE_SECS", "120").to_i
|
||||||
|
end
|
||||||
|
|
||||||
def ops_http_rails_url
|
def ops_http_rails_url
|
||||||
ENV.fetch("OPS_HTTP_RAILS_URL", "http://edge/up")
|
ENV.fetch("OPS_HTTP_RAILS_URL", "http://edge/up")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ de:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sitzungen
|
sessions: Sitzungen
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Kosten
|
||||||
stream_nodes: Stream-Knoten
|
stream_nodes: Stream-Knoten
|
||||||
password: Passwort
|
password: Passwort
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
@@ -50,6 +51,9 @@ de:
|
|||||||
announcement_created: Hinweis gespeichert
|
announcement_created: Hinweis gespeichert
|
||||||
announcement_updated: Hinweis aktualisiert
|
announcement_updated: Hinweis aktualisiert
|
||||||
announcement_destroyed: Hinweis gelöscht
|
announcement_destroyed: Hinweis gelöscht
|
||||||
|
cost_entry_created: Kostenposition gespeichert.
|
||||||
|
cost_entry_updated: Kostenposition aktualisiert.
|
||||||
|
cost_entry_destroyed: Kostenposition gelöscht.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Ja"
|
yes: "Ja"
|
||||||
@@ -111,6 +115,7 @@ de:
|
|||||||
table:
|
table:
|
||||||
match: Spiel
|
match: Spiel
|
||||||
status: Status
|
status: Status
|
||||||
|
client: Client
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
start: Start
|
start: Start
|
||||||
link: Link
|
link: Link
|
||||||
@@ -276,6 +281,7 @@ de:
|
|||||||
duration: Dauer
|
duration: Dauer
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
disconnects: Verbindungsabbrüche
|
disconnects: Verbindungsabbrüche
|
||||||
|
client: Client
|
||||||
link: Link
|
link: Link
|
||||||
detail: Details
|
detail: Details
|
||||||
regia: Regie
|
regia: Regie
|
||||||
@@ -308,6 +314,11 @@ de:
|
|||||||
opponent: Gegner
|
opponent: Gegner
|
||||||
operator: Operator
|
operator: Operator
|
||||||
platform: Plattform
|
platform: Plattform
|
||||||
|
client_os: System
|
||||||
|
app_version: App-Version
|
||||||
|
device: Gerät
|
||||||
|
os_version: OS-Version
|
||||||
|
carrier: Mobilfunkanbieter
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
quality: Qualität
|
quality: Qualität
|
||||||
min_quality: Min. Qualität
|
min_quality: Min. Qualität
|
||||||
@@ -346,6 +357,7 @@ de:
|
|||||||
generate_button: Regie-Link erzeugen
|
generate_button: Regie-Link erzeugen
|
||||||
ingest:
|
ingest:
|
||||||
none: "—"
|
none: "—"
|
||||||
|
decommissioned: "(Knoten entfernt)"
|
||||||
role:
|
role:
|
||||||
home: Home-lab
|
home: Home-lab
|
||||||
lab: Lab
|
lab: Lab
|
||||||
@@ -363,6 +375,10 @@ de:
|
|||||||
layer: Ebene
|
layer: Ebene
|
||||||
layer_move: Mausbewegungen
|
layer_move: Mausbewegungen
|
||||||
layer_click: Klicks
|
layer_click: Klicks
|
||||||
|
devices:
|
||||||
|
mobile: Mobil
|
||||||
|
tablet: Tablet
|
||||||
|
desktop: Desktop
|
||||||
index:
|
index:
|
||||||
title: Website-Analytics
|
title: Website-Analytics
|
||||||
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
|
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
|
||||||
@@ -383,14 +399,24 @@ de:
|
|||||||
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
|
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
|
||||||
heatmap_moves: Mausbewegungs-Karte
|
heatmap_moves: Mausbewegungs-Karte
|
||||||
heatmap_clicks: Klickkarte
|
heatmap_clicks: Klickkarte
|
||||||
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel.
|
heatmap_hint: Farbiges Overlay auf dem %{device}-Screenshot. Grün = wenig, Rot = viel. Daten werden nicht zwischen Geräten gemischt.
|
||||||
|
device_tabs_label: Gerät
|
||||||
preview_title: Seiten-Screenshot
|
preview_title: Seiten-Screenshot
|
||||||
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
|
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_missing: Noch kein Screenshot für %{device}. Seite auf diesem Gerät mit Analytics-Einwilligung besuchen und erneut versuchen.
|
||||||
snapshot_meta: "Screenshot %{device} · %{at}"
|
snapshot_meta: "Screenshot %{device} · %{at}"
|
||||||
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
|
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
|
||||||
heatmap_title: Klickkarte
|
heatmap_title: Klickkarte
|
||||||
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
|
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
|
||||||
|
preview:
|
||||||
|
title: Vorschau ohne Tracking
|
||||||
|
lead: Öffentliche Website durchsuchen ohne Pageviews, Klicks, Bewegungen oder Heatmap-Screenshots zu speichern. Für Produktionstests.
|
||||||
|
active: Staff-Vorschau in diesem Browser aktiv (7 Tage oder bis Deaktivierung).
|
||||||
|
enable: Staff-Vorschau aktivieren
|
||||||
|
disable: Vorschau deaktivieren
|
||||||
|
open_site: Website öffnen
|
||||||
|
enabled: Staff-Vorschau aktiviert. Dieser Browser speichert keine Analytics.
|
||||||
|
disabled: Staff-Vorschau deaktiviert.
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ en:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Costs
|
||||||
stream_nodes: Stream nodes
|
stream_nodes: Stream nodes
|
||||||
password: Password
|
password: Password
|
||||||
logout: Log out
|
logout: Log out
|
||||||
@@ -50,6 +51,9 @@ en:
|
|||||||
announcement_created: Notice saved
|
announcement_created: Notice saved
|
||||||
announcement_updated: Notice updated
|
announcement_updated: Notice updated
|
||||||
announcement_destroyed: Notice deleted
|
announcement_destroyed: Notice deleted
|
||||||
|
cost_entry_created: Cost entry saved.
|
||||||
|
cost_entry_updated: Cost entry updated.
|
||||||
|
cost_entry_destroyed: Cost entry deleted.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Yes"
|
yes: "Yes"
|
||||||
@@ -111,6 +115,7 @@ en:
|
|||||||
table:
|
table:
|
||||||
match: Match
|
match: Match
|
||||||
status: Status
|
status: Status
|
||||||
|
client: Client
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
start: Start
|
start: Start
|
||||||
link: Link
|
link: Link
|
||||||
@@ -276,6 +281,7 @@ en:
|
|||||||
duration: Duration
|
duration: Duration
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
disconnects: Disconnects
|
disconnects: Disconnects
|
||||||
|
client: Client
|
||||||
link: Link
|
link: Link
|
||||||
detail: Details
|
detail: Details
|
||||||
regia: Control
|
regia: Control
|
||||||
@@ -308,6 +314,11 @@ en:
|
|||||||
opponent: Opponent
|
opponent: Opponent
|
||||||
operator: Operator
|
operator: Operator
|
||||||
platform: Platform
|
platform: Platform
|
||||||
|
client_os: OS
|
||||||
|
app_version: App version
|
||||||
|
device: Device
|
||||||
|
os_version: OS version
|
||||||
|
carrier: Carrier
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
quality: Quality
|
quality: Quality
|
||||||
min_quality: Min quality
|
min_quality: Min quality
|
||||||
@@ -346,6 +357,7 @@ en:
|
|||||||
generate_button: Generate control link
|
generate_button: Generate control link
|
||||||
ingest:
|
ingest:
|
||||||
none: "—"
|
none: "—"
|
||||||
|
decommissioned: "(node removed)"
|
||||||
role:
|
role:
|
||||||
home: Home-lab
|
home: Home-lab
|
||||||
lab: Lab
|
lab: Lab
|
||||||
@@ -363,6 +375,10 @@ en:
|
|||||||
layer: Layer
|
layer: Layer
|
||||||
layer_move: Mouse moves
|
layer_move: Mouse moves
|
||||||
layer_click: Clicks
|
layer_click: Clicks
|
||||||
|
devices:
|
||||||
|
mobile: Mobile
|
||||||
|
tablet: Tablet
|
||||||
|
desktop: Desktop
|
||||||
index:
|
index:
|
||||||
title: Site analytics
|
title: Site analytics
|
||||||
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
|
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
|
||||||
@@ -383,14 +399,84 @@ en:
|
|||||||
scroll_hint: "Average %{avg}% · max %{max}%"
|
scroll_hint: "Average %{avg}% · max %{max}%"
|
||||||
heatmap_moves: Mouse-move map
|
heatmap_moves: Mouse-move map
|
||||||
heatmap_clicks: Click map
|
heatmap_clicks: Click map
|
||||||
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high.
|
heatmap_hint: Colored overlay on the %{device} screenshot. Green = low, red = high. Data is never mixed across devices.
|
||||||
|
device_tabs_label: Device
|
||||||
preview_title: Page screenshot
|
preview_title: Page screenshot
|
||||||
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
|
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_missing: No screenshot for %{device} yet. Visit the page on that device with analytics consent, then retry in a few seconds.
|
||||||
snapshot_meta: "Screenshot %{device} · captured %{at}"
|
snapshot_meta: "Screenshot %{device} · captured %{at}"
|
||||||
no_points: No aggregated points for this layer in the period.
|
no_points: No aggregated points for this layer in the period.
|
||||||
heatmap_title: Click map
|
heatmap_title: Click map
|
||||||
no_clicks: No aggregated clicks for this page in the period.
|
no_clicks: No aggregated clicks for this page in the period.
|
||||||
|
preview:
|
||||||
|
title: Preview without tracking
|
||||||
|
lead: Browse the public site without recording pageviews, clicks, moves or heatmap screenshots. Useful for production smoke tests.
|
||||||
|
active: Staff preview is active in this browser (7 days or until disabled).
|
||||||
|
enable: Enable staff preview
|
||||||
|
disable: Disable preview
|
||||||
|
open_site: Open site
|
||||||
|
enabled: Staff preview enabled. This browser will not record analytics.
|
||||||
|
disabled: Staff preview disabled.
|
||||||
|
|
||||||
|
costs:
|
||||||
|
index:
|
||||||
|
title: Cost analysis
|
||||||
|
lead: Enter monthly platform costs and compare cost, usage and revenue. KPIs recalculate automatically when you edit entries.
|
||||||
|
month: Month
|
||||||
|
apply: Apply
|
||||||
|
entries_title: Cost entries for the month
|
||||||
|
entries_lead: Add multiple line items (e.g. servers, storage, bandwidth). Month total is the sum of entries.
|
||||||
|
add_entry: Add entry
|
||||||
|
no_entries: No cost entries for this month. Add platform cost to see KPIs.
|
||||||
|
clubs_title: Allocated cost per club
|
||||||
|
clubs_lead: Infrastructure cost is split proportionally by streamed hours in the month.
|
||||||
|
no_clubs: No ended streams in this month.
|
||||||
|
trends_title: Trends (last 12 months)
|
||||||
|
kpi:
|
||||||
|
platform_cost: Platform cost
|
||||||
|
revenue: Revenue collected
|
||||||
|
margin: Margin
|
||||||
|
margin_pct: Margin %
|
||||||
|
hours: Streamed hours
|
||||||
|
sessions: Streams
|
||||||
|
clubs_active: Active clubs
|
||||||
|
storage: Replay storage
|
||||||
|
cost_per_hour: Cost / stream hour
|
||||||
|
cost_per_session: Cost / stream
|
||||||
|
cost_per_club: Cost / active club
|
||||||
|
revenue_per_hour: Revenue / stream hour
|
||||||
|
cost_per_gb: Cost / GB storage
|
||||||
|
storage_note: Current cumulative storage (not monthly).
|
||||||
|
charts:
|
||||||
|
cost_revenue: Cost vs revenue
|
||||||
|
cost_per_hour: Cost per streamed hour
|
||||||
|
hours: Streamed hours
|
||||||
|
table:
|
||||||
|
label: Item
|
||||||
|
amount: Amount
|
||||||
|
notes: Notes
|
||||||
|
club: Club
|
||||||
|
plan: Plan
|
||||||
|
hours: Hours
|
||||||
|
hours_share: Hour share
|
||||||
|
allocated_cost: Allocated cost
|
||||||
|
revenue: Revenue
|
||||||
|
margin: Margin
|
||||||
|
cost_per_hour: €/hour
|
||||||
|
cost_per_session: €/stream
|
||||||
|
storage: Storage
|
||||||
|
entries:
|
||||||
|
new_title: New cost entry
|
||||||
|
edit_title: Edit cost entry
|
||||||
|
month: Month
|
||||||
|
label: Description
|
||||||
|
label_hint: E.g. Hetzner, stream nodes, S3, egress.
|
||||||
|
amount: Amount (€)
|
||||||
|
notes: Notes
|
||||||
|
save: Save
|
||||||
|
cancel: Cancel
|
||||||
|
delete: Delete
|
||||||
|
delete_confirm: Delete this cost entry?
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ es:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sesiones
|
sessions: Sesiones
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Costes
|
||||||
stream_nodes: Nodos stream
|
stream_nodes: Nodos stream
|
||||||
password: Contraseña
|
password: Contraseña
|
||||||
logout: Salir
|
logout: Salir
|
||||||
@@ -50,6 +51,9 @@ es:
|
|||||||
announcement_created: Aviso guardado
|
announcement_created: Aviso guardado
|
||||||
announcement_updated: Aviso actualizado
|
announcement_updated: Aviso actualizado
|
||||||
announcement_destroyed: Aviso eliminado
|
announcement_destroyed: Aviso eliminado
|
||||||
|
cost_entry_created: Partida de coste guardada.
|
||||||
|
cost_entry_updated: Partida de coste actualizada.
|
||||||
|
cost_entry_destroyed: Partida de coste eliminada.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Sí"
|
yes: "Sí"
|
||||||
@@ -111,6 +115,7 @@ es:
|
|||||||
table:
|
table:
|
||||||
match: Partido
|
match: Partido
|
||||||
status: Estado
|
status: Estado
|
||||||
|
client: Cliente
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
start: Inicio
|
start: Inicio
|
||||||
link: Enlace
|
link: Enlace
|
||||||
@@ -276,6 +281,7 @@ es:
|
|||||||
duration: Duración
|
duration: Duración
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
disconnects: Desconexiones
|
disconnects: Desconexiones
|
||||||
|
client: Cliente
|
||||||
link: Enlace
|
link: Enlace
|
||||||
detail: Detalle
|
detail: Detalle
|
||||||
regia: Regie
|
regia: Regie
|
||||||
@@ -308,6 +314,11 @@ es:
|
|||||||
opponent: Rival
|
opponent: Rival
|
||||||
operator: Operador
|
operator: Operador
|
||||||
platform: Plataforma
|
platform: Plataforma
|
||||||
|
client_os: Sistema
|
||||||
|
app_version: Versión app
|
||||||
|
device: Dispositivo
|
||||||
|
os_version: Versión OS
|
||||||
|
carrier: Operador móvil
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
quality: Calidad
|
quality: Calidad
|
||||||
min_quality: Calidad mínima
|
min_quality: Calidad mínima
|
||||||
@@ -346,6 +357,7 @@ es:
|
|||||||
generate_button: Generar enlace de regie
|
generate_button: Generar enlace de regie
|
||||||
ingest:
|
ingest:
|
||||||
none: "—"
|
none: "—"
|
||||||
|
decommissioned: "(nodo eliminado)"
|
||||||
role:
|
role:
|
||||||
home: Home-lab
|
home: Home-lab
|
||||||
lab: Lab
|
lab: Lab
|
||||||
@@ -363,6 +375,10 @@ es:
|
|||||||
layer: Capa
|
layer: Capa
|
||||||
layer_move: Movimientos del ratón
|
layer_move: Movimientos del ratón
|
||||||
layer_click: Clics
|
layer_click: Clics
|
||||||
|
devices:
|
||||||
|
mobile: Móvil
|
||||||
|
tablet: Tablet
|
||||||
|
desktop: Escritorio
|
||||||
index:
|
index:
|
||||||
title: Analytics del sitio
|
title: Analytics del sitio
|
||||||
lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
|
lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
|
||||||
@@ -383,14 +399,24 @@ es:
|
|||||||
scroll_hint: "Media %{avg}% · máximo %{max}%"
|
scroll_hint: "Media %{avg}% · máximo %{max}%"
|
||||||
heatmap_moves: Mapa de movimientos
|
heatmap_moves: Mapa de movimientos
|
||||||
heatmap_clicks: Mapa de clics
|
heatmap_clicks: Mapa de clics
|
||||||
heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho.
|
heatmap_hint: Superposición de color sobre la captura %{device}. Verde = poco, rojo = mucho. Los datos no se mezclan entre dispositivos.
|
||||||
|
device_tabs_label: Dispositivo
|
||||||
preview_title: Captura de página
|
preview_title: Captura de página
|
||||||
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
|
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_missing: Aún no hay captura para %{device}. Visita la página con ese dispositivo y consentimiento analytics, y vuelve a intentarlo.
|
||||||
snapshot_meta: "Captura %{device} · %{at}"
|
snapshot_meta: "Captura %{device} · %{at}"
|
||||||
no_points: No hay puntos agregados para esta capa en el periodo.
|
no_points: No hay puntos agregados para esta capa en el periodo.
|
||||||
heatmap_title: Mapa de clics
|
heatmap_title: Mapa de clics
|
||||||
no_clicks: No hay clics agregados para esta página en el periodo.
|
no_clicks: No hay clics agregados para esta página en el periodo.
|
||||||
|
preview:
|
||||||
|
title: Vista previa sin seguimiento
|
||||||
|
lead: Navega el sitio público sin registrar pageviews, clics, movimientos ni capturas heatmap. Útil para pruebas en producción.
|
||||||
|
active: Vista previa staff activa en este navegador (7 días o hasta desactivar).
|
||||||
|
enable: Activar vista previa staff
|
||||||
|
disable: Desactivar vista previa
|
||||||
|
open_site: Abrir sitio
|
||||||
|
enabled: Vista previa staff activada. Este navegador no registrará analytics.
|
||||||
|
disabled: Vista previa staff desactivada.
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ fr:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Coûts
|
||||||
stream_nodes: Nœuds stream
|
stream_nodes: Nœuds stream
|
||||||
password: Mot de passe
|
password: Mot de passe
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
@@ -50,6 +51,9 @@ fr:
|
|||||||
announcement_created: Alerte enregistrée
|
announcement_created: Alerte enregistrée
|
||||||
announcement_updated: Alerte mise à jour
|
announcement_updated: Alerte mise à jour
|
||||||
announcement_destroyed: Alerte supprimée
|
announcement_destroyed: Alerte supprimée
|
||||||
|
cost_entry_created: Poste de coût enregistré.
|
||||||
|
cost_entry_updated: Poste de coût mis à jour.
|
||||||
|
cost_entry_destroyed: Poste de coût supprimé.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Oui"
|
yes: "Oui"
|
||||||
@@ -111,6 +115,7 @@ fr:
|
|||||||
table:
|
table:
|
||||||
match: Match
|
match: Match
|
||||||
status: Statut
|
status: Statut
|
||||||
|
client: Client
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
start: Début
|
start: Début
|
||||||
link: Lien
|
link: Lien
|
||||||
@@ -276,6 +281,7 @@ fr:
|
|||||||
duration: Durée
|
duration: Durée
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
disconnects: Déconnexions
|
disconnects: Déconnexions
|
||||||
|
client: Client
|
||||||
link: Lien
|
link: Lien
|
||||||
detail: Détail
|
detail: Détail
|
||||||
regia: Régie
|
regia: Régie
|
||||||
@@ -308,6 +314,11 @@ fr:
|
|||||||
opponent: Adversaire
|
opponent: Adversaire
|
||||||
operator: Opérateur
|
operator: Opérateur
|
||||||
platform: Plateforme
|
platform: Plateforme
|
||||||
|
client_os: Système
|
||||||
|
app_version: Version app
|
||||||
|
device: Appareil
|
||||||
|
os_version: Version OS
|
||||||
|
carrier: Opérateur mobile
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
quality: Qualité
|
quality: Qualité
|
||||||
min_quality: Qualité mini
|
min_quality: Qualité mini
|
||||||
@@ -346,6 +357,7 @@ fr:
|
|||||||
generate_button: Générer le lien de régie
|
generate_button: Générer le lien de régie
|
||||||
ingest:
|
ingest:
|
||||||
none: "—"
|
none: "—"
|
||||||
|
decommissioned: "(nœud retiré)"
|
||||||
role:
|
role:
|
||||||
home: Home-lab
|
home: Home-lab
|
||||||
lab: Lab
|
lab: Lab
|
||||||
@@ -363,6 +375,10 @@ fr:
|
|||||||
layer: Couche
|
layer: Couche
|
||||||
layer_move: Mouvements souris
|
layer_move: Mouvements souris
|
||||||
layer_click: Clics
|
layer_click: Clics
|
||||||
|
devices:
|
||||||
|
mobile: Mobile
|
||||||
|
tablet: Tablette
|
||||||
|
desktop: Ordinateur
|
||||||
index:
|
index:
|
||||||
title: Analytics du site
|
title: Analytics du site
|
||||||
lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
|
lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
|
||||||
@@ -383,14 +399,24 @@ fr:
|
|||||||
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
|
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
|
||||||
heatmap_moves: Carte des mouvements
|
heatmap_moves: Carte des mouvements
|
||||||
heatmap_clicks: Carte des clics
|
heatmap_clicks: Carte des clics
|
||||||
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort.
|
heatmap_hint: Superposition colorée sur la capture %{device}. Vert = faible, rouge = fort. Les données ne sont pas mélangées entre appareils.
|
||||||
|
device_tabs_label: Appareil
|
||||||
preview_title: Capture de page
|
preview_title: Capture de page
|
||||||
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
|
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_missing: Pas encore de capture pour %{device}. Visitez la page sur cet appareil avec consentement analytics, puis réessayez.
|
||||||
snapshot_meta: "Capture %{device} · %{at}"
|
snapshot_meta: "Capture %{device} · %{at}"
|
||||||
no_points: Aucun point agrégé pour cette couche sur la période.
|
no_points: Aucun point agrégé pour cette couche sur la période.
|
||||||
heatmap_title: Carte des clics
|
heatmap_title: Carte des clics
|
||||||
no_clicks: Aucun clic agrégé pour cette page sur la période.
|
no_clicks: Aucun clic agrégé pour cette page sur la période.
|
||||||
|
preview:
|
||||||
|
title: Aperçu sans suivi
|
||||||
|
lead: Parcourez le site public sans enregistrer pages vues, clics, mouvements ni captures heatmap. Utile pour tester en production.
|
||||||
|
active: Aperçu staff actif sur ce navigateur (7 jours ou jusqu'à désactivation).
|
||||||
|
enable: Activer l'aperçu staff
|
||||||
|
disable: Désactiver l'aperçu
|
||||||
|
open_site: Ouvrir le site
|
||||||
|
enabled: Aperçu staff activé. Ce navigateur ne enregistrera pas d'analytics.
|
||||||
|
disabled: Aperçu staff désactivé.
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ it:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessioni
|
sessions: Sessioni
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Costi
|
||||||
stream_nodes: Nodi stream
|
stream_nodes: Nodi stream
|
||||||
password: Password
|
password: Password
|
||||||
logout: Esci
|
logout: Esci
|
||||||
@@ -54,6 +55,9 @@ it:
|
|||||||
announcement_created: Avviso salvato
|
announcement_created: Avviso salvato
|
||||||
announcement_updated: Avviso aggiornato
|
announcement_updated: Avviso aggiornato
|
||||||
announcement_destroyed: Avviso eliminato
|
announcement_destroyed: Avviso eliminato
|
||||||
|
cost_entry_created: Voce di costo registrata.
|
||||||
|
cost_entry_updated: Voce di costo aggiornata.
|
||||||
|
cost_entry_destroyed: Voce di costo eliminata.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Sì"
|
yes: "Sì"
|
||||||
@@ -115,6 +119,7 @@ it:
|
|||||||
table:
|
table:
|
||||||
match: Partita
|
match: Partita
|
||||||
status: Stato
|
status: Stato
|
||||||
|
client: Client
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
start: Inizio
|
start: Inizio
|
||||||
link: Link
|
link: Link
|
||||||
@@ -297,6 +302,7 @@ it:
|
|||||||
duration: Durata
|
duration: Durata
|
||||||
ingest: Ingest
|
ingest: Ingest
|
||||||
disconnects: Disconnessioni
|
disconnects: Disconnessioni
|
||||||
|
client: Client
|
||||||
link: Link
|
link: Link
|
||||||
detail: Dettaglio
|
detail: Dettaglio
|
||||||
regia: Regia
|
regia: Regia
|
||||||
@@ -329,6 +335,11 @@ it:
|
|||||||
opponent: Avversario
|
opponent: Avversario
|
||||||
operator: Operatore
|
operator: Operatore
|
||||||
platform: Piattaforma
|
platform: Piattaforma
|
||||||
|
client_os: Sistema
|
||||||
|
app_version: Versione app
|
||||||
|
device: Dispositivo
|
||||||
|
os_version: Versione OS
|
||||||
|
carrier: Operatore telefonico
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
quality: Qualità
|
quality: Qualità
|
||||||
min_quality: Qualità minima
|
min_quality: Qualità minima
|
||||||
@@ -367,6 +378,7 @@ it:
|
|||||||
generate_button: Genera link regia
|
generate_button: Genera link regia
|
||||||
ingest:
|
ingest:
|
||||||
none: "—"
|
none: "—"
|
||||||
|
decommissioned: "(nodo rimosso)"
|
||||||
role:
|
role:
|
||||||
home: Home-lab
|
home: Home-lab
|
||||||
lab: Lab
|
lab: Lab
|
||||||
@@ -384,6 +396,10 @@ it:
|
|||||||
layer: Livello
|
layer: Livello
|
||||||
layer_move: Movimenti mouse
|
layer_move: Movimenti mouse
|
||||||
layer_click: Click
|
layer_click: Click
|
||||||
|
devices:
|
||||||
|
mobile: Mobile
|
||||||
|
tablet: Tablet
|
||||||
|
desktop: Desktop
|
||||||
index:
|
index:
|
||||||
title: Analytics sito
|
title: Analytics sito
|
||||||
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
|
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
|
||||||
@@ -404,14 +420,84 @@ it:
|
|||||||
scroll_hint: "Media %{avg}% · massimo %{max}%"
|
scroll_hint: "Media %{avg}% · massimo %{max}%"
|
||||||
heatmap_moves: Mappa movimenti mouse
|
heatmap_moves: Mappa movimenti mouse
|
||||||
heatmap_clicks: Mappa click
|
heatmap_clicks: Mappa click
|
||||||
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto.
|
heatmap_hint: Overlay colorato sullo screenshot %{device}. Verde = poco, rosso = molto. I dati non vengono mischiati tra dispositivi.
|
||||||
|
device_tabs_label: Dispositivo
|
||||||
preview_title: Screenshot pagina
|
preview_title: Screenshot pagina
|
||||||
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
|
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_missing: Nessuno screenshot per %{device}. Visita la pagina sul sito con quel dispositivo e consenso analytics, poi riprova tra qualche secondo.
|
||||||
snapshot_meta: "Screenshot %{device} · catturato %{at}"
|
snapshot_meta: "Screenshot %{device} · catturato %{at}"
|
||||||
no_points: Nessun punto aggregato per questo livello nel periodo.
|
no_points: Nessun punto aggregato per questo livello nel periodo.
|
||||||
heatmap_title: Mappa click
|
heatmap_title: Mappa click
|
||||||
no_clicks: Nessun click aggregato per questa pagina nel periodo.
|
no_clicks: Nessun click aggregato per questa pagina nel periodo.
|
||||||
|
preview:
|
||||||
|
title: Anteprima senza tracciamento
|
||||||
|
lead: Visita il sito pubblico senza registrare pageview, click, movimenti o screenshot heatmap. Utile per test in produzione.
|
||||||
|
active: Modalità anteprima attiva su questo browser (7 giorni o fino a disattivazione).
|
||||||
|
enable: Attiva anteprima staff
|
||||||
|
disable: Disattiva anteprima
|
||||||
|
open_site: Apri sito
|
||||||
|
enabled: Anteprima staff attivata. Il sito non registrerà analytics su questo browser.
|
||||||
|
disabled: Anteprima staff disattivata.
|
||||||
|
|
||||||
|
costs:
|
||||||
|
index:
|
||||||
|
title: Analisi costi
|
||||||
|
lead: Inserisci i costi mensili della piattaforma e confronta costo, utilizzo e revenue. I KPI si ricalcolano automaticamente quando modifichi le voci.
|
||||||
|
month: Mese
|
||||||
|
apply: Applica
|
||||||
|
entries_title: Voci di costo del mese
|
||||||
|
entries_lead: Puoi aggiungere più voci (es. server, storage, banda). Il totale è la somma delle voci del mese.
|
||||||
|
add_entry: Aggiungi voce
|
||||||
|
no_entries: Nessuna voce di costo per questo mese. Aggiungi il costo della piattaforma per vedere i KPI.
|
||||||
|
clubs_title: Costo allocato per società
|
||||||
|
clubs_lead: Il costo infrastruttura è ripartito proporzionalmente alle ore trasmesse nel mese.
|
||||||
|
no_clubs: Nessuna diretta terminata in questo mese.
|
||||||
|
trends_title: Trend (ultimi 12 mesi)
|
||||||
|
kpi:
|
||||||
|
platform_cost: Costo piattaforma
|
||||||
|
revenue: Revenue incassata
|
||||||
|
margin: Margine
|
||||||
|
margin_pct: Margine %
|
||||||
|
hours: Ore trasmesse
|
||||||
|
sessions: Dirette
|
||||||
|
clubs_active: Società attive
|
||||||
|
storage: Storage replay
|
||||||
|
cost_per_hour: Costo / ora stream
|
||||||
|
cost_per_session: Costo / diretta
|
||||||
|
cost_per_club: Costo / società attiva
|
||||||
|
revenue_per_hour: Revenue / ora stream
|
||||||
|
cost_per_gb: Costo / GB storage
|
||||||
|
storage_note: Storage cumulativo attuale (non mensile).
|
||||||
|
charts:
|
||||||
|
cost_revenue: Costo vs revenue
|
||||||
|
cost_per_hour: Costo per ora trasmessa
|
||||||
|
hours: Ore trasmesse
|
||||||
|
table:
|
||||||
|
label: Voce
|
||||||
|
amount: Importo
|
||||||
|
notes: Note
|
||||||
|
club: Società
|
||||||
|
plan: Piano
|
||||||
|
hours: Ore
|
||||||
|
hours_share: Quota ore
|
||||||
|
allocated_cost: Costo allocato
|
||||||
|
revenue: Revenue
|
||||||
|
margin: Margine
|
||||||
|
cost_per_hour: €/ora
|
||||||
|
cost_per_session: €/diretta
|
||||||
|
storage: Storage
|
||||||
|
entries:
|
||||||
|
new_title: Nuova voce di costo
|
||||||
|
edit_title: Modifica voce di costo
|
||||||
|
month: Mese
|
||||||
|
label: Descrizione
|
||||||
|
label_hint: Es. Hetzner, nodi stream CPX, S3, banda egress.
|
||||||
|
amount: Importo (€)
|
||||||
|
notes: Note
|
||||||
|
save: Salva
|
||||||
|
cancel: Annulla
|
||||||
|
delete: Elimina
|
||||||
|
delete_confirm: Eliminare questa voce di costo?
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ de:
|
|||||||
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
|
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
|
||||||
company_title: Sitz
|
company_title: Sitz
|
||||||
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
|
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_address: Adresse
|
||||||
label_vat: USt-IdNr.
|
label_vat: USt-IdNr.
|
||||||
form_title: Nachricht senden
|
form_title: Nachricht senden
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ en:
|
|||||||
privacy_body: GDPR rights, consent and personal data. Write to
|
privacy_body: GDPR rights, consent and personal data. Write to
|
||||||
company_title: Company details
|
company_title: Company details
|
||||||
company_lead: Service provider and data controller.
|
company_lead: Service provider and data controller.
|
||||||
|
social_title: Follow us on social media
|
||||||
label_address: Address
|
label_address: Address
|
||||||
label_vat: VAT number
|
label_vat: VAT number
|
||||||
form_title: Send a message
|
form_title: Send a message
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ es:
|
|||||||
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
|
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
|
||||||
company_title: Sede
|
company_title: Sede
|
||||||
company_lead: Prestador del servicio y responsable del tratamiento.
|
company_lead: Prestador del servicio y responsable del tratamiento.
|
||||||
|
social_title: Síguenos también en redes sociales
|
||||||
label_address: Dirección
|
label_address: Dirección
|
||||||
label_vat: NIF / IVA
|
label_vat: NIF / IVA
|
||||||
form_title: Enviar un mensaje
|
form_title: Enviar un mensaje
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ fr:
|
|||||||
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
|
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
|
||||||
company_title: Siège
|
company_title: Siège
|
||||||
company_lead: Prestataire du service et responsable du traitement.
|
company_lead: Prestataire du service et responsable du traitement.
|
||||||
|
social_title: Suivez-nous aussi sur les réseaux sociaux
|
||||||
label_address: Adresse
|
label_address: Adresse
|
||||||
label_vat: N° de TVA
|
label_vat: N° de TVA
|
||||||
form_title: Envoyer un message
|
form_title: Envoyer un message
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ it:
|
|||||||
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
|
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
|
||||||
company_title: Sede
|
company_title: Sede
|
||||||
company_lead: Titolare del servizio e del trattamento dei dati.
|
company_lead: Titolare del servizio e del trattamento dei dati.
|
||||||
|
social_title: Seguici anche sui social
|
||||||
label_address: Indirizzo
|
label_address: Indirizzo
|
||||||
label_vat: Partita IVA
|
label_vat: Partita IVA
|
||||||
form_title: Invia un messaggio
|
form_title: Invia un messaggio
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ de:
|
|||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
account: Konto
|
account: Konto
|
||||||
signup: Team registrieren
|
signup: Team registrieren
|
||||||
|
analytics_preview:
|
||||||
|
badge: Staff-Vorschau — Analytics deaktiviert
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||||
live: Live
|
live: Live
|
||||||
manage_cookies: Cookies verwalten
|
manage_cookies: Cookies verwalten
|
||||||
copyright: "© 2026 Emiliano Frascaro – USt-IdNr. 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – USt-IdNr. 14230270960"
|
||||||
responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen.
|
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:
|
cookie:
|
||||||
title: Cookies und Datenschutz
|
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}.
|
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}.
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ en:
|
|||||||
logout: Log out
|
logout: Log out
|
||||||
account: Account
|
account: Account
|
||||||
signup: Register a team
|
signup: Register a team
|
||||||
|
analytics_preview:
|
||||||
|
badge: Staff preview — analytics disabled
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Every match, every event, for those who can't be there
|
tagline: Every match, every event, for those who can't be there
|
||||||
live: Live
|
live: Live
|
||||||
manage_cookies: Manage cookies
|
manage_cookies: Manage cookies
|
||||||
copyright: "© 2026 Emiliano Frascaro – VAT 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – VAT 14230270960"
|
||||||
responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it.
|
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:
|
cookie:
|
||||||
title: Cookies and privacy
|
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}.
|
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}.
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ es:
|
|||||||
logout: Salir
|
logout: Salir
|
||||||
account: Cuenta
|
account: Cuenta
|
||||||
signup: Registrar equipo
|
signup: Registrar equipo
|
||||||
|
analytics_preview:
|
||||||
|
badge: Vista previa staff — analytics desactivados
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Cada partido, cada evento, para quien no puede estar
|
tagline: Cada partido, cada evento, para quien no puede estar
|
||||||
live: Directos
|
live: Directos
|
||||||
manage_cookies: Gestionar cookies
|
manage_cookies: Gestionar cookies
|
||||||
copyright: "© 2026 Emiliano Frascaro – NIF 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – NIF 14230270960"
|
||||||
responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican.
|
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:
|
cookie:
|
||||||
title: Cookies y privacidad
|
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}.
|
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}.
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ fr:
|
|||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
account: Compte
|
account: Compte
|
||||||
signup: Inscrire une équipe
|
signup: Inscrire une équipe
|
||||||
|
analytics_preview:
|
||||||
|
badge: Aperçu staff — analytics désactivés
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||||
live: Directs
|
live: Directs
|
||||||
manage_cookies: Gérer les cookies
|
manage_cookies: Gérer les cookies
|
||||||
copyright: "© 2026 Emiliano Frascaro – TVA 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – TVA 14230270960"
|
||||||
responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient.
|
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:
|
cookie:
|
||||||
title: Cookies et confidentialité
|
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}.
|
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}.
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ it:
|
|||||||
logout: Esci
|
logout: Esci
|
||||||
account: Account
|
account: Account
|
||||||
signup: Registra squadra
|
signup: Registra squadra
|
||||||
|
analytics_preview:
|
||||||
|
badge: Anteprima staff — analytics disattivati
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||||
live: Dirette
|
live: Dirette
|
||||||
manage_cookies: Gestisci cookie
|
manage_cookies: Gestisci cookie
|
||||||
copyright: "© 2026 Emiliano Frascaro – P. IVA 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – P. IVA 14230270960"
|
||||||
responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.
|
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:
|
cookie:
|
||||||
title: Cookie e privacy
|
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}.
|
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}.
|
||||||
|
|||||||
@@ -130,8 +130,12 @@ Rails.application.routes.draw do
|
|||||||
delete :clear_kill_switch
|
delete :clear_kill_switch
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
get "costs", to: "costs#index", as: :costs
|
||||||
|
resources :cost_entries, path: "costs/entries", except: %i[index show]
|
||||||
get "analytics", to: "analytics#index", as: :analytics
|
get "analytics", to: "analytics#index", as: :analytics
|
||||||
get "analytics/page", to: "analytics#show", as: :analytics_page
|
get "analytics/page", to: "analytics#show", as: :analytics_page
|
||||||
|
post "analytics/preview", to: "analytics#preview_enable", as: :analytics_preview
|
||||||
|
delete "analytics/preview", to: "analytics#preview_disable"
|
||||||
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
||||||
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
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreatePlatformCostEntries < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :platform_cost_entries, id: :uuid do |t|
|
||||||
|
t.date :month, null: false
|
||||||
|
t.string :label, null: false, default: "Piattaforma produzione"
|
||||||
|
t.integer :amount_cents, null: false
|
||||||
|
t.text :notes
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :platform_cost_entries, :month
|
||||||
|
add_index :platform_cost_entries, %i[month label]
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+27
-1
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_28_180000) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -313,6 +313,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
|
|||||||
t.index ["slug"], name: "index_plans_on_slug", unique: true
|
t.index ["slug"], name: "index_plans_on_slug", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "platform_cost_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.date "month", null: false
|
||||||
|
t.string "label", default: "Piattaforma produzione", null: false
|
||||||
|
t.integer "amount_cents", null: false
|
||||||
|
t.text "notes"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["month", "label"], name: "index_platform_cost_entries_on_month_and_label"
|
||||||
|
t.index ["month"], name: "index_platform_cost_entries_on_month"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "recordings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "recordings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "stream_session_id", null: false
|
t.uuid "stream_session_id", null: false
|
||||||
t.uuid "team_id", null: false
|
t.uuid "team_id", null: false
|
||||||
@@ -338,10 +349,15 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
|
|||||||
t.datetime "expiry_warning_sent_at"
|
t.datetime "expiry_warning_sent_at"
|
||||||
t.string "youtube_video_id"
|
t.string "youtube_video_id"
|
||||||
t.datetime "youtube_published_at"
|
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 ["deleted_at"], name: "index_recordings_on_deleted_at"
|
||||||
t.index ["expires_at"], name: "index_recordings_on_expires_at"
|
t.index ["expires_at"], name: "index_recordings_on_expires_at"
|
||||||
t.index ["privacy_status"], name: "index_recordings_on_privacy_status"
|
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_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 ["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", "status"], name: "index_recordings_on_team_id_and_status"
|
||||||
t.index ["team_id"], name: "index_recordings_on_team_id"
|
t.index ["team_id"], name: "index_recordings_on_team_id"
|
||||||
@@ -427,6 +443,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
|
|||||||
t.uuid "stream_node_id"
|
t.uuid "stream_node_id"
|
||||||
t.string "min_quality_preset", default: "auto", null: false
|
t.string "min_quality_preset", default: "auto", null: false
|
||||||
t.boolean "audio_muted", default: false, 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 ["match_id"], name: "index_stream_sessions_on_match_id"
|
||||||
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
|
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
|
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Dati simulati per la pagina Admin → Costi (trend 12 mesi, società, revenue).
|
||||||
|
# Uso: bundle exec rails runner db/seeds/cost_analytics_demo.rb
|
||||||
|
|
||||||
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
|
||||||
|
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
||||||
|
u.name = "Coach Demo"
|
||||||
|
u.password = "Password123"
|
||||||
|
u.role = "coach"
|
||||||
|
end
|
||||||
|
|
||||||
|
clubs_data = [
|
||||||
|
{ name: "Tigers Volley", sport: "volleyball", plan: "premium_full", sessions: 14, hours: 18.5, revenue_yearly: 19900 },
|
||||||
|
{ name: "ASD Eagles Milano", sport: "volleyball", plan: "premium_light", sessions: 9, hours: 11.0, revenue_yearly: 5900 },
|
||||||
|
{ name: "Volley Stars Roma", sport: "volleyball", plan: "premium_full", sessions: 6, hours: 8.0, revenue_yearly: 19900 },
|
||||||
|
{ name: "Basket Juventus U18", sport: "basketball", plan: "premium_light", sessions: 4, hours: 5.5, revenue_yearly: 5900 },
|
||||||
|
{ name: "Padova Beach", sport: "volleyball", plan: "free", sessions: 2, hours: 2.0, revenue_yearly: 0 }
|
||||||
|
]
|
||||||
|
|
||||||
|
sport_keys = {
|
||||||
|
"volleyball" => "pallavolo",
|
||||||
|
"basketball" => "basket"
|
||||||
|
}
|
||||||
|
|
||||||
|
clubs = clubs_data.map do |row|
|
||||||
|
club = Club.find_or_create_by!(name: row[:name]) { |c| c.sport = row[:sport] }
|
||||||
|
Billing::AssignPlan.call(club: club, plan_slug: row[:plan])
|
||||||
|
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
|
||||||
|
team = club.teams.find_or_create_by!(name: "Prima squadra") do |t|
|
||||||
|
t.sport_key = sport_keys.fetch(row[:sport], "pallavolo")
|
||||||
|
end
|
||||||
|
{ club: club, team: team, **row }
|
||||||
|
end
|
||||||
|
|
||||||
|
month_costs = [
|
||||||
|
{ offset: 11, hetzner: 89.00, stream: 12.50, s3: 8.20 },
|
||||||
|
{ offset: 10, hetzner: 89.00, stream: 18.00, s3: 9.10 },
|
||||||
|
{ offset: 9, hetzner: 89.00, stream: 24.50, s3: 10.40 },
|
||||||
|
{ offset: 8, hetzner: 89.00, stream: 31.00, s3: 11.80 },
|
||||||
|
{ offset: 7, hetzner: 89.00, stream: 22.00, s3: 12.20 },
|
||||||
|
{ offset: 6, hetzner: 89.00, stream: 15.50, s3: 13.50 },
|
||||||
|
{ offset: 5, hetzner: 89.00, stream: 28.00, s3: 14.80 },
|
||||||
|
{ offset: 4, hetzner: 89.00, stream: 35.50, s3: 15.60 },
|
||||||
|
{ offset: 3, hetzner: 89.00, stream: 42.00, s3: 16.90 },
|
||||||
|
{ offset: 2, hetzner: 89.00, stream: 38.00, s3: 18.20 },
|
||||||
|
{ offset: 1, hetzner: 89.00, stream: 45.00, s3: 19.50 },
|
||||||
|
{ offset: 0, hetzner: 89.00, stream: 52.00, s3: 21.00 }
|
||||||
|
]
|
||||||
|
|
||||||
|
PlatformCostEntry.delete_all
|
||||||
|
|
||||||
|
month_costs.each do |row|
|
||||||
|
month = Time.zone.today.beginning_of_month - row[:offset].months
|
||||||
|
[
|
||||||
|
{ label: "Hetzner home + backup", amount: row[:hetzner] },
|
||||||
|
{ label: "Nodi stream CPX (overflow)", amount: row[:stream] },
|
||||||
|
{ label: "Garage/S3 replay storage", amount: row[:s3] }
|
||||||
|
].each do |entry|
|
||||||
|
PlatformCostEntry.create!(
|
||||||
|
month: month,
|
||||||
|
label: entry[:label],
|
||||||
|
amount_cents: (entry[:amount] * 100).round,
|
||||||
|
notes: "Dato demo seed"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sessioni e pagamenti nel mese corrente (distribuiti tra società)
|
||||||
|
current_month = Time.zone.today.beginning_of_month
|
||||||
|
clubs.each_with_index do |row, index|
|
||||||
|
club = row[:club]
|
||||||
|
team = row[:team]
|
||||||
|
sessions_count = row[:sessions]
|
||||||
|
total_secs = (row[:hours] * 3600).round
|
||||||
|
|
||||||
|
sessions_count.times do |n|
|
||||||
|
secs = (total_secs / sessions_count.to_f).round
|
||||||
|
day = current_month + (n % 27).days
|
||||||
|
started = day.change(hour: 10 + (n % 6), min: 0)
|
||||||
|
ended = started + secs.seconds
|
||||||
|
match = team.matches.create!(
|
||||||
|
opponent_name: "Avversario demo #{n + 1}",
|
||||||
|
sport_key: team.sport_key,
|
||||||
|
scheduled_at: started
|
||||||
|
)
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: coach,
|
||||||
|
platform: "matchlivetv",
|
||||||
|
status: "ended",
|
||||||
|
started_at: started,
|
||||||
|
ended_at: ended,
|
||||||
|
total_duration_secs: secs
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if row[:revenue_yearly].positive?
|
||||||
|
payment = Billing::Payment.find_or_initialize_by(club: club, paid_at: current_month + (index + 3).days)
|
||||||
|
payment.assign_attributes(
|
||||||
|
status: "paid",
|
||||||
|
provider: "stripe",
|
||||||
|
amount_cents: row[:revenue_yearly],
|
||||||
|
currency: "EUR",
|
||||||
|
plan_slug: row[:plan]
|
||||||
|
)
|
||||||
|
payment.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
# Storage simulato (cumulativo)
|
||||||
|
sample_session = team.matches.last&.stream_sessions&.first
|
||||||
|
next unless sample_session
|
||||||
|
|
||||||
|
rec = Recording.find_or_initialize_by(stream_session: sample_session)
|
||||||
|
rec.assign_attributes(
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
privacy_status: "unlisted",
|
||||||
|
storage_backend: "s3",
|
||||||
|
storage_policy: "retained",
|
||||||
|
byte_size: (500_000_000 + index * 180_000_000),
|
||||||
|
duration_secs: 3600
|
||||||
|
)
|
||||||
|
rec.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Cost analytics demo OK:"
|
||||||
|
puts " Voci costo: #{PlatformCostEntry.count} (12 mesi)"
|
||||||
|
puts " Sessioni demo mese corrente: #{StreamSession.where(status: 'ended', ended_at: current_month..current_month.end_of_month).count}"
|
||||||
|
puts " Admin: http://localhost:3000/admin/costs"
|
||||||
|
puts " Login admin: admin / AdminPass123"
|
||||||
@@ -20,4 +20,37 @@ namespace :recordings do
|
|||||||
orphan = Mediamtx::CleanupOrphanPaths.new.call
|
orphan = Mediamtx::CleanupOrphanPaths.new.call
|
||||||
puts "Path MediaMTX orfani rimossi: #{orphan.removed} (saltati: #{orphan.skipped})"
|
puts "Path MediaMTX orfani rimossi: #{orphan.removed} (saltati: #{orphan.skipped})"
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
(function () {
|
||||||
|
var trend = window.adminCostTrend || [];
|
||||||
|
var i18n = window.adminCostI18n || {};
|
||||||
|
if (!trend.length || typeof Chart === "undefined") return;
|
||||||
|
|
||||||
|
function monthLabel(monthStr) {
|
||||||
|
var parts = monthStr.split("-");
|
||||||
|
if (parts.length < 2) return monthStr;
|
||||||
|
var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1);
|
||||||
|
return d.toLocaleDateString([], { month: "short", year: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels = trend.map(function (row) {
|
||||||
|
return monthLabel(row.month);
|
||||||
|
});
|
||||||
|
|
||||||
|
var chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 300 },
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { maxTicksLimit: 12, color: "#9a9aad", font: { size: 10 } },
|
||||||
|
grid: { color: "rgba(255,255,255,0.06)" }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { color: "#9a9aad", font: { size: 10 } },
|
||||||
|
grid: { color: "rgba(255,255,255,0.06)" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: { legend: { labels: { color: "#ccc", boxWidth: 12 } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
var euroTicks = {
|
||||||
|
ticks: {
|
||||||
|
color: "#9a9aad",
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: function (v) {
|
||||||
|
return "€" + v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var costRevenueCanvas = document.getElementById("chart-cost-revenue");
|
||||||
|
if (costRevenueCanvas) {
|
||||||
|
new Chart(costRevenueCanvas, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: i18n.cost || "Cost",
|
||||||
|
data: trend.map(function (r) { return (r.cost_cents || 0) / 100; }),
|
||||||
|
backgroundColor: "rgba(229, 57, 53, 0.65)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.revenue || "Revenue",
|
||||||
|
data: trend.map(function (r) { return (r.revenue_cents || 0) / 100; }),
|
||||||
|
backgroundColor: "rgba(67, 160, 71, 0.65)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.margin || "Margin",
|
||||||
|
data: trend.map(function (r) { return (r.margin_cents || 0) / 100; }),
|
||||||
|
backgroundColor: "rgba(255, 193, 7, 0.55)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
scales: {
|
||||||
|
x: chartOptions.scales.x,
|
||||||
|
y: { ...chartOptions.scales.y, ...euroTicks }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var costHourCanvas = document.getElementById("chart-cost-hour");
|
||||||
|
if (costHourCanvas) {
|
||||||
|
new Chart(costHourCanvas, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: i18n.costPerHour || "Cost/hour",
|
||||||
|
data: trend.map(function (r) {
|
||||||
|
return r.cost_per_hour_cents ? r.cost_per_hour_cents / 100 : 0;
|
||||||
|
}),
|
||||||
|
borderColor: "#e53935",
|
||||||
|
backgroundColor: "rgba(229, 57, 53, 0.12)",
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 2,
|
||||||
|
borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
scales: {
|
||||||
|
x: chartOptions.scales.x,
|
||||||
|
y: { ...chartOptions.scales.y, ...euroTicks }
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var hoursCanvas = document.getElementById("chart-hours");
|
||||||
|
if (hoursCanvas) {
|
||||||
|
new Chart(hoursCanvas, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: i18n.hours || "Hours",
|
||||||
|
data: trend.map(function (r) { return r.hours || 0; }),
|
||||||
|
borderColor: "#43a047",
|
||||||
|
backgroundColor: "rgba(67, 160, 71, 0.12)",
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 2,
|
||||||
|
borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -693,6 +693,13 @@ body.admin-body {
|
|||||||
background: #3a1515;
|
background: #3a1515;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-device-tab-count {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-locale-panel {
|
.admin-locale-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -793,6 +800,12 @@ body.admin-body {
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-analytics-preview__status {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: #a5f0b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-filter-count {
|
.admin-filter-count {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
return document.body && document.body.getAttribute("data-ga-id");
|
return document.body && document.body.getAttribute("data-ga-id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnalyticsSuppressed() {
|
||||||
|
var meta = document.querySelector("meta[name='mltv-analytics-suppress']");
|
||||||
|
return meta && meta.getAttribute("content") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
function loadGoogleAnalytics() {
|
function loadGoogleAnalytics() {
|
||||||
var id = measurementId();
|
var id = measurementId();
|
||||||
if (!id || window.__mltvGaLoaded) return;
|
if (!id || window.__mltvGaLoaded) return;
|
||||||
@@ -68,6 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyConsent(consent) {
|
function applyConsent(consent) {
|
||||||
|
if (isAnalyticsSuppressed()) return;
|
||||||
if (consent && consent.analytics) {
|
if (consent && consent.analytics) {
|
||||||
loadGoogleAnalytics();
|
loadGoogleAnalytics();
|
||||||
if (typeof window.mltvSiteAnalyticsStart === "function") {
|
if (typeof window.mltvSiteAnalyticsStart === "function") {
|
||||||
@@ -94,6 +100,10 @@
|
|||||||
|
|
||||||
if (acceptAll) {
|
if (acceptAll) {
|
||||||
acceptAll.addEventListener("click", function () {
|
acceptAll.addEventListener("click", function () {
|
||||||
|
if (isAnalyticsSuppressed()) {
|
||||||
|
hideBanner(banner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var c = writeConsent({ analytics: true });
|
var c = writeConsent({ analytics: true });
|
||||||
applyConsent(c);
|
applyConsent(c);
|
||||||
hideBanner(banner);
|
hideBanner(banner);
|
||||||
|
|||||||
@@ -1307,7 +1307,7 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
.store-badges--footer {
|
.store-badges--footer {
|
||||||
margin-top: 12px;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
.store-badges--footer .store-badge {
|
.store-badges--footer .store-badge {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
@@ -2459,10 +2459,94 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.stripe-secure--compact i { font-size: 0.95rem; color: #888; }
|
.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 { 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 { flex: 1 1 100%; margin-top: 4px; }
|
||||||
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
||||||
.site-footer__legal p:last-child { margin-bottom: 0; }
|
.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 {
|
.compare-table-wrap {
|
||||||
margin-top: 28px;
|
margin-top: 28px;
|
||||||
}
|
}
|
||||||
@@ -3465,3 +3549,31 @@ a.replay-archive__thumb:hover {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mltv-preview-badge {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(20, 20, 28, 0.94);
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mltv-preview-badge__link {
|
||||||
|
color: #ff8a80;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mltv-preview-badge__link:hover {
|
||||||
|
color: #ff5252;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnalyticsSuppressed() {
|
||||||
|
var meta = document.querySelector("meta[name='mltv-analytics-suppress']");
|
||||||
|
return meta && meta.getAttribute("content") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
function hasAnalyticsConsent() {
|
function hasAnalyticsConsent() {
|
||||||
try {
|
try {
|
||||||
var raw = localStorage.getItem(STORAGE_KEY);
|
var raw = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -104,7 +109,7 @@
|
|||||||
|
|
||||||
function flush(useBeacon) {
|
function flush(useBeacon) {
|
||||||
flushMovesIntoQueue();
|
flushMovesIntoQueue();
|
||||||
if (!queue.length || !hasAnalyticsConsent()) {
|
if (!queue.length || !hasAnalyticsConsent() || isAnalyticsSuppressed()) {
|
||||||
queue = [];
|
queue = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,7 +241,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function captureSnapshot() {
|
function captureSnapshot() {
|
||||||
if (!hasAnalyticsConsent() || alreadyCapturedToday()) return;
|
if (!hasAnalyticsConsent() || isAnalyticsSuppressed() || alreadyCapturedToday()) return;
|
||||||
loadHtml2Canvas(function (html2canvas) {
|
loadHtml2Canvas(function (html2canvas) {
|
||||||
var size = pageSize();
|
var size = pageSize();
|
||||||
html2canvas(document.documentElement, {
|
html2canvas(document.documentElement, {
|
||||||
@@ -281,6 +286,7 @@
|
|||||||
function start() {
|
function start() {
|
||||||
if (started) return;
|
if (started) return;
|
||||||
if (excludedPath(location.pathname)) return;
|
if (excludedPath(location.pathname)) return;
|
||||||
|
if (isAnalyticsSuppressed()) return;
|
||||||
if (!hasAnalyticsConsent()) return;
|
if (!hasAnalyticsConsent()) return;
|
||||||
started = true;
|
started = true;
|
||||||
maxScroll = scrollPct();
|
maxScroll = scrollPct();
|
||||||
|
|||||||
@@ -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_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")
|
expect(session.mediamtx_internal_rtmp_url).to eq("rtmp://10.0.0.2:1935")
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -109,6 +109,28 @@ RSpec.describe "Account API", type: :request do
|
|||||||
expect(user.reload.password_reset_digest).to be_present
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "non va in 500 se in produzione manca SMTP" do
|
||||||
|
allow(MatchLiveTv).to receive(:smtp_configured?).and_return(false)
|
||||||
|
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||||
|
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non va in 500 se SMTP rifiuta il destinatario" do
|
||||||
|
allow_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_now)
|
||||||
|
.and_raise(Net::SMTPFatalError.new("556 5.1.10 invalid destination domain"))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||||
|
}.not_to raise_error
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
it "returns the same message for unknown emails" do
|
it "returns the same message for unknown emails" do
|
||||||
expect {
|
expect {
|
||||||
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
||||||
|
|||||||
@@ -35,10 +35,40 @@ RSpec.describe "Admin analytics", type: :request do
|
|||||||
expect(response.body).to include("40")
|
expect(response.body).to include("40")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "mostra la heatmap di una pagina" do
|
it "mostra la heatmap di una pagina con tab dispositivo" do
|
||||||
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("admin-heatmap")
|
expect(response.body).to include("admin-heatmap")
|
||||||
expect(response.body).to include("/prezzi")
|
expect(response.body).to include("/prezzi")
|
||||||
|
expect(response.body).to include("is-active")
|
||||||
|
expect(response.body).to include(I18n.t("admin.analytics.devices.desktop"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra solo i punti del dispositivo selezionato" do
|
||||||
|
AnalyticsPageCell.create!(
|
||||||
|
day: Time.zone.today,
|
||||||
|
page_path: "/prezzi",
|
||||||
|
device: "mobile",
|
||||||
|
cell_x: 5,
|
||||||
|
cell_y: 8,
|
||||||
|
click_count: 9,
|
||||||
|
move_count: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
get admin_analytics_page_path, params: { page_path: "/prezzi", device: "mobile", layer: "click" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("x":5")
|
||||||
|
expect(response.body).to include("c":9")
|
||||||
|
expect(response.body).not_to include("x":10")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "attiva e disattiva l'anteprima staff" do
|
||||||
|
post admin_analytics_preview_path, params: { return_to: "/" }
|
||||||
|
expect(response).to redirect_to("/")
|
||||||
|
expect(response.cookies[Analytics::Suppress::COOKIE_NAME]).to eq("1")
|
||||||
|
|
||||||
|
delete admin_analytics_preview_path
|
||||||
|
expect(response).to redirect_to(admin_analytics_path)
|
||||||
|
expect(response.cookies[Analytics::Suppress::COOKIE_NAME]).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Admin costs", type: :request do
|
||||||
|
let!(:admin) { AdminAccount.create!(username: "ops-costs", password: "Password123") }
|
||||||
|
let(:month) { Time.zone.today.beginning_of_month }
|
||||||
|
|
||||||
|
before do
|
||||||
|
post admin_login_path, params: { username: admin.username, password: "Password123" }
|
||||||
|
PlatformCostEntry.create!(month: month, label: "Piattaforma", amount_cents: 8_500)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra la dashboard costi" do
|
||||||
|
get admin_costs_path, params: { month: month.strftime("%Y-%m") }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include(I18n.t("admin.costs.index.title"))
|
||||||
|
expect(response.body).to include("85.00")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "crea, aggiorna ed elimina una voce di costo" do
|
||||||
|
post admin_cost_entries_path,
|
||||||
|
params: {
|
||||||
|
platform_cost_entry: {
|
||||||
|
month: month.strftime("%Y-%m"),
|
||||||
|
label: "Stream CPX",
|
||||||
|
amount_euros: "42.50",
|
||||||
|
notes: "overflow nodes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
|
||||||
|
entry = PlatformCostEntry.find_by(label: "Stream CPX")
|
||||||
|
expect(entry.amount_cents).to eq(4_250)
|
||||||
|
|
||||||
|
patch admin_cost_entry_path(entry),
|
||||||
|
params: { platform_cost_entry: { month: month.strftime("%Y-%m"), label: "Stream CPX", amount_euros: "50" } }
|
||||||
|
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
|
||||||
|
expect(entry.reload.amount_cents).to eq(5_000)
|
||||||
|
|
||||||
|
delete admin_cost_entry_path(entry)
|
||||||
|
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
|
||||||
|
expect(PlatformCostEntry.find_by(id: entry.id)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -60,4 +60,31 @@ RSpec.describe "Admin sessions index", type: :request do
|
|||||||
expect(response.body).to include("Dettaglio sessione").or include("Session details")
|
expect(response.body).to include("Dettaglio sessione").or include("Session details")
|
||||||
expect(response.body).to include(session_a.id)
|
expect(response.body).to include(session_a.id)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -52,6 +52,24 @@ RSpec.describe "Analytics ingest", type: :request do
|
|||||||
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
|
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "ignora eventi con cookie anteprima staff" do
|
||||||
|
expect do
|
||||||
|
post "/analytics/events",
|
||||||
|
params: {
|
||||||
|
events: [
|
||||||
|
{ type: "pageview", path: "/prezzi-suppress", device: "desktop", ts: Time.current.to_i * 1000 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers: { "Cookie" => "#{Analytics::Suppress::COOKIE_NAME}=1" },
|
||||||
|
as: :json
|
||||||
|
end.not_to change { AnalyticsPageStat.where(page_path: "/prezzi-suppress").count }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body["accepted"]).to eq(0)
|
||||||
|
expect(body["suppressed"]).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
it "rifiuta path esclusi" do
|
it "rifiuta path esclusi" do
|
||||||
post "/analytics/events",
|
post "/analytics/events",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -29,16 +29,36 @@ RSpec.describe "Analytics snapshots", type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "rifiuta path esclusi" do
|
it "rifiuta path esclusi" do
|
||||||
post "/analytics/snapshot",
|
expect do
|
||||||
params: {
|
post "/analytics/snapshot",
|
||||||
path: "/admin/analytics",
|
params: {
|
||||||
device: "desktop",
|
path: "/admin/analytics",
|
||||||
width: 1440,
|
device: "desktop",
|
||||||
height: 900,
|
width: 1440,
|
||||||
image: jpeg_upload
|
height: 900,
|
||||||
}
|
image: jpeg_upload
|
||||||
|
}
|
||||||
|
end.not_to change(AnalyticsPageSnapshot, :count)
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_content)
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
expect(AnalyticsPageSnapshot.count).to eq(0)
|
end
|
||||||
|
|
||||||
|
it "salta snapshot con cookie anteprima staff" do
|
||||||
|
expect do
|
||||||
|
post "/analytics/snapshot",
|
||||||
|
params: {
|
||||||
|
path: "/prezzi-suppress",
|
||||||
|
device: "desktop",
|
||||||
|
width: 1440,
|
||||||
|
height: 2200,
|
||||||
|
image: jpeg_upload
|
||||||
|
},
|
||||||
|
headers: { "Cookie" => "#{Analytics::Suppress::COOKIE_NAME}=1" }
|
||||||
|
end.not_to change(AnalyticsPageSnapshot, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body["skipped"]).to eq(true)
|
||||||
|
expect(body["suppressed"]).to eq(true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Public replay show youtube-only", type: :request do
|
||||||
|
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
|
||||||
|
let!(:user) { User.create!(email: "pub-replay@test.com", name: "Coach", password: "Password123", role: "coach") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.ago) }
|
||||||
|
let!(:session) do
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "youtube", status: "ended", ended_at: 1.hour.ago,
|
||||||
|
youtube_broadcast_id: "bcast"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra embed YouTube senza storage_key" do
|
||||||
|
Recording.create!(
|
||||||
|
stream_session: session,
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
storage_policy: "temporary",
|
||||||
|
storage_key: nil,
|
||||||
|
youtube_video_id: "publicYtVid01",
|
||||||
|
youtube_verified_at: Time.current,
|
||||||
|
local_media_purged_at: Time.current,
|
||||||
|
privacy_status: "public",
|
||||||
|
title: "Derby",
|
||||||
|
expires_at: nil,
|
||||||
|
metadata: { "source_platform" => "youtube" }
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/replay/#{session.id}"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("youtube.com/embed/publicYtVid01")
|
||||||
|
expect(response.body).not_to include("id=\"replay-player\"")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra player MP4 per retained con storage_key" do
|
||||||
|
mltv = StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago
|
||||||
|
)
|
||||||
|
Recording.create!(
|
||||||
|
stream_session: mltv,
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
storage_policy: "retained",
|
||||||
|
storage_key: "teams/#{team.id}/sessions/#{mltv.id}/replay.mp4",
|
||||||
|
privacy_status: "public",
|
||||||
|
title: "Casa",
|
||||||
|
expires_at: 30.days.from_now,
|
||||||
|
metadata: { "source_platform" => "matchlivetv" }
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/replay/#{mltv.id}"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("id=\"replay-player\"")
|
||||||
|
expect(response.body).not_to include("youtube.com/embed/")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "preferisce il player MP4 locale se la copia temporanea YouTube è ancora presente" do
|
||||||
|
Recording.create!(
|
||||||
|
stream_session: session,
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
storage_policy: "temporary",
|
||||||
|
storage_key: "temporary_replays/teams/#{team.id}/sessions/#{session.id}/replay.mp4",
|
||||||
|
youtube_video_id: "publicYtVid01",
|
||||||
|
youtube_verified_at: Time.current,
|
||||||
|
privacy_status: "unlisted",
|
||||||
|
title: "Derby temp",
|
||||||
|
expires_at: nil,
|
||||||
|
metadata: { "source_platform" => "youtube" }
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/replay/#{session.id}"
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("id=\"replay-player\"")
|
||||||
|
expect(response.body).not_to include("youtube.com/embed/publicYtVid01")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Api::V1 team recordings replay_source", type: :request do
|
||||||
|
let!(:user) { User.create!(email: "rec-api@test.it", name: "U", password: "Password123", role: "coach") }
|
||||||
|
let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
|
||||||
|
let!(:owner) { club.club_memberships.create!(user: user, role: "owner") }
|
||||||
|
let!(:team) { club.teams.create!(name: "T", sport: "volleyball") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", scheduled_at: 1.day.ago) }
|
||||||
|
|
||||||
|
def auth_headers
|
||||||
|
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||||
|
token = response.parsed_body["access_token"]
|
||||||
|
{ "Authorization" => "Bearer #{token}" }
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "youtube-only: replay_source youtube, no playback/download" do
|
||||||
|
session = StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "youtube", status: "ended", ended_at: 1.hour.ago,
|
||||||
|
youtube_broadcast_id: "bcast1"
|
||||||
|
)
|
||||||
|
Recording.create!(
|
||||||
|
stream_session: session,
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
storage_policy: "temporary",
|
||||||
|
storage_key: nil,
|
||||||
|
youtube_video_id: "ytVideoId123",
|
||||||
|
youtube_verified_at: Time.current,
|
||||||
|
local_media_purged_at: Time.current,
|
||||||
|
privacy_status: "public",
|
||||||
|
expires_at: nil,
|
||||||
|
metadata: { "source_platform" => "youtube" }
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/api/v1/teams/#{team.id}/recordings", headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
item = response.parsed_body.find { |r| r["session_id"] == session.id } ||
|
||||||
|
response.parsed_body.find { |r| r["youtube_video_id"] == "ytVideoId123" }
|
||||||
|
expect(item).to be_present
|
||||||
|
expect(item["replay_source"]).to eq("youtube")
|
||||||
|
expect(item["youtube_watch_url"]).to include("ytVideoId123")
|
||||||
|
expect(item["playback_url"]).to be_nil
|
||||||
|
expect(item["download_enabled"]).to eq(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "matchlivetv retained: replay_source matchlivetv with playback" do
|
||||||
|
session = StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago
|
||||||
|
)
|
||||||
|
Recording.create!(
|
||||||
|
stream_session: session,
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
storage_policy: "retained",
|
||||||
|
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4",
|
||||||
|
privacy_status: "public",
|
||||||
|
expires_at: 30.days.from_now,
|
||||||
|
metadata: { "source_platform" => "matchlivetv" }
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/api/v1/teams/#{team.id}/recordings", headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
item = response.parsed_body.find { |r| r["session_id"] == session.id }
|
||||||
|
expect(item["replay_source"]).to eq("matchlivetv")
|
||||||
|
expect(item["playback_url"]).to be_present
|
||||||
|
expect(item["download_enabled"]).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "download API rejects youtube-only recording" do
|
||||||
|
session = StreamSession.create!(
|
||||||
|
match: match, user: user, platform: "youtube", status: "ended", ended_at: 1.hour.ago
|
||||||
|
)
|
||||||
|
recording = Recording.create!(
|
||||||
|
stream_session: session,
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
storage_policy: "temporary",
|
||||||
|
storage_key: nil,
|
||||||
|
youtube_video_id: "ytVideoId999",
|
||||||
|
youtube_verified_at: Time.current,
|
||||||
|
privacy_status: "public"
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/api/v1/recordings/#{recording.id}/download", headers: auth_headers
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Admin::CostAnalytics do
|
||||||
|
let!(:coach) do
|
||||||
|
User.create!(email: "cost-coach@test.it", name: "Coach", password: "Password123", role: "coach")
|
||||||
|
end
|
||||||
|
let!(:club) { Club.create!(name: "Tigers Club", sport: "volleyball") }
|
||||||
|
let!(:team) { club.teams.create!(name: "Tigers U16", sport_key: "pallavolo") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Rival", sport_key: "pallavolo") }
|
||||||
|
let(:month) { Date.new(2024, 3, 1) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
PlatformCostEntry.delete_all
|
||||||
|
PlatformCostEntry.create!(
|
||||||
|
month: month,
|
||||||
|
label: "Hetzner",
|
||||||
|
amount_cents: 10_000
|
||||||
|
)
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: coach,
|
||||||
|
platform: "matchlivetv",
|
||||||
|
status: "ended",
|
||||||
|
started_at: month + 1.day + 10.hours,
|
||||||
|
ended_at: month + 1.day + 11.hours,
|
||||||
|
total_duration_secs: 3600
|
||||||
|
)
|
||||||
|
Billing::Payment.create!(
|
||||||
|
club: club,
|
||||||
|
amount_cents: 5_900,
|
||||||
|
currency: "EUR",
|
||||||
|
status: "paid",
|
||||||
|
provider: "stripe",
|
||||||
|
paid_at: month + 2.days
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calcola KPI e allocazione per società" do
|
||||||
|
analytics = Admin::CostAnalytics.new(month: month)
|
||||||
|
summary = analytics.summary
|
||||||
|
|
||||||
|
expect(summary[:cost_cents]).to eq(10_000)
|
||||||
|
expect(summary[:sessions]).to eq(1)
|
||||||
|
expect(summary[:hours]).to eq(1.0)
|
||||||
|
expect(summary[:revenue_cents]).to eq(5_900)
|
||||||
|
expect(summary[:margin_cents]).to eq(-4_100)
|
||||||
|
expect(summary[:cost_per_hour_cents]).to eq(10_000)
|
||||||
|
|
||||||
|
clubs = analytics.club_breakdown
|
||||||
|
expect(clubs.size).to eq(1)
|
||||||
|
expect(clubs.first[:club_name]).to eq("Tigers Club")
|
||||||
|
expect(clubs.first[:allocated_cost_cents]).to eq(10_000)
|
||||||
|
expect(clubs.first[:revenue_cents]).to eq(5_900)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "ricalcola dopo modifica delle voci di costo" do
|
||||||
|
PlatformCostEntry.create!(month: month, label: "S3", amount_cents: 2_000)
|
||||||
|
|
||||||
|
summary = Admin::CostAnalytics.new(month: month).summary
|
||||||
|
expect(summary[:cost_cents]).to eq(12_000)
|
||||||
|
expect(summary[:cost_per_session_cents]).to eq(12_000)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -40,6 +40,44 @@ RSpec.describe Ops::HealthChecks do
|
|||||||
|
|
||||||
expect(Ops::Incident.find_by(fingerprint: "disk_space:root").status).to eq("resolved")
|
expect(Ops::Incident.find_by(fingerprint: "disk_space:root").status).to eq("resolved")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "chiude gli incidenti overflow anche se la fingerprint sana è diversa" do
|
||||||
|
Ops::Incident.create!(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
status: "open",
|
||||||
|
title: "Capacità stream al massimo",
|
||||||
|
message: "overflow=3/3",
|
||||||
|
metadata: {},
|
||||||
|
fingerprint: "stream_overflow:at_max",
|
||||||
|
occurrence_count: 1,
|
||||||
|
first_seen_at: Time.current,
|
||||||
|
last_seen_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_any_instance_of(described_class).to receive(:check_stream_overflow).and_return(
|
||||||
|
described_class::Finding.new(
|
||||||
|
kind: "stream_overflow", severity: "info", healthy: true,
|
||||||
|
title: "Overflow streaming OK", message: "OK", metadata: {}, fingerprint: "stream_overflow:ok"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
%i[
|
||||||
|
check_disk_root check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||||
|
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
||||||
|
].each do |method|
|
||||||
|
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||||
|
described_class::Finding.new(
|
||||||
|
kind: "test", severity: "info", healthy: true,
|
||||||
|
title: "OK", message: "OK", metadata: {}, fingerprint: "#{method}:ok"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
allow_any_instance_of(described_class).to receive(:public_check_due?).and_return(false)
|
||||||
|
|
||||||
|
described_class.new.call
|
||||||
|
|
||||||
|
expect(Ops::Incident.find_by(fingerprint: "stream_overflow:at_max").status).to eq("resolved")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#summary" do
|
describe "#summary" do
|
||||||
|
|||||||
@@ -52,4 +52,28 @@ RSpec.describe Ops::IncidentRecorder do
|
|||||||
expect(incident.reload.status).to eq("resolved")
|
expect(incident.reload.status).to eq("resolved")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe ".resolve_kind" do
|
||||||
|
it "chiude tutti gli incidenti aperti di quel kind" do
|
||||||
|
at_max = described_class.record(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
title: "Max",
|
||||||
|
message: "max",
|
||||||
|
fingerprint: "stream_overflow:at_max"
|
||||||
|
)
|
||||||
|
orphan = described_class.record(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
title: "Idle",
|
||||||
|
message: "idle",
|
||||||
|
fingerprint: "stream_overflow:orphan_idle"
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.resolve_kind("stream_overflow")
|
||||||
|
|
||||||
|
expect(at_max.reload.status).to eq("resolved")
|
||||||
|
expect(orphan.reload.status).to eq("resolved")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user