Aggiunge modulo Replay, Garage dev e YouTube a livello società.

Pipeline registrazione/upload con storage S3, archivio web e app, proxy replay
per il browser, OAuth YouTube sulla pagina club (Premium Full) e footer legale.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 07:53:11 +02:00
parent a87cda156b
commit 1f273f849d
87 changed files with 2952 additions and 195 deletions

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ infra/certs/
infra/recordings/ infra/recordings/
infra/.env infra/.env
infra/.env.production.generated infra/.env.production.generated
infra/garage/dev-credentials.env
# OS # OS
.DS_Store .DS_Store

View File

@@ -17,6 +17,7 @@ gem "attr_encrypted"
gem "google-apis-youtube_v3" gem "google-apis-youtube_v3"
gem "faraday" gem "faraday"
gem "stripe" gem "stripe"
gem "aws-sdk-s3", "~> 1.0", require: false
gem "kamal", require: false gem "kamal", require: false
group :development, :test do group :development, :test do

View File

@@ -81,6 +81,25 @@ GEM
ast (2.4.3) ast (2.4.3)
attr_encrypted (4.2.0) attr_encrypted (4.2.0)
encryptor (~> 3.0.0) encryptor (~> 3.0.0)
aws-eventstream (1.4.0)
aws-partitions (1.1256.0)
aws-sdk-core (3.251.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.129.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.225.0)
aws-sdk-core (~> 3, >= 3.248.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.22) bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2) bcrypt_pbkdf (1.1.2)
@@ -158,6 +177,7 @@ GEM
prism (>= 1.3.0) prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.5) json (2.19.5)
jwt (3.2.0) jwt (3.2.0)
base64 base64
@@ -416,6 +436,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
aasm aasm
attr_encrypted attr_encrypted
aws-sdk-s3 (~> 1.0)
bcrypt (~> 3.1.7) bcrypt (~> 3.1.7)
bootsnap bootsnap
brakeman brakeman

View File

@@ -10,7 +10,7 @@ module Admin
def show def show
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active") @subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" } @plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.includes(:youtube_credential).order(:name) @teams = @club.teams.order(:name)
end end
def grant_comped def grant_comped

View File

@@ -0,0 +1,105 @@
module Api
module V1
class RecordingsController < BaseController
before_action :set_recording
def update
authorize_manage!
@recording.update!(recording_update_attrs)
render json: recording_json(@recording)
end
def destroy
authorize_manage!
Recordings::Delete.new(@recording).call
head :no_content
end
def download
url = Recordings::DownloadUrl.new(@recording, viewer: current_user).call
render json: { download_url: url, expires_in_seconds: MatchLiveTv.replay_download_url_ttl_seconds }
rescue Recordings::DownloadUrl::NotAllowed
render json: { error: "Download non disponibile per il tuo piano", error_code: "download_not_allowed" },
status: :forbidden
rescue ArgumentError => e
render json: { error: e.message }, status: :unprocessable_entity
end
def publish_youtube
authorize_manage!
ent = @recording.team.entitlements
unless ent.premium_full? && ent.youtube_enabled?
return render json: { error: "Republicazione YouTube con Premium Full" }, status: :forbidden
end
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
render json: { queued: true, message: "Pubblicazione su YouTube in corso" }
end
private
def set_recording
team_ids = current_user.manageable_teams.map(&:id)
@recording = Recording.not_deleted
.includes(stream_session: :match)
.where(team_id: team_ids)
.find(params[:id])
end
def authorize_manage!
team = @recording.team
return if team.club.owned_by?(current_user)
return if current_user.manageable_teams.exists?(id: team.id)
render json: { error: "Non autorizzato" }, status: :forbidden
end
def recording_update_attrs
p = params.require(:recording).permit(:privacy_status, :title)
p = p.to_h
p[:privacy_status] = "unlisted" if p[:privacy_status] == "private"
if params.dig(:recording, :metadata).present?
p[:metadata] = @recording.metadata.merge(
params[:recording][:metadata].to_unsafe_h.stringify_keys
)
end
p
end
def recording_json(recording)
session = recording.stream_session
match = session.match
ent = recording.team.entitlements
{
id: recording.id,
session_id: session.id,
match_id: match.id,
title: recording.title_or_default,
opponent_name: match.opponent_name,
team_name: match.team.name,
team_id: recording.team_id,
status: recording.status,
status_label: recording.status_label,
privacy_status: recording.privacy_status,
ended_at: session.ended_at,
recorded_at: recording.recorded_at_or_fallback,
duration_secs: recording.duration_secs,
duration_label: recording.duration_label,
byte_size: recording.byte_size,
byte_size_label: recording.byte_size_label,
view_count: recording.view_count,
views_label: recording.views_label,
replay_url: recording.replay_url,
playback_url: recording.playback_stream_url,
thumbnail_url: recording.thumbnail_url,
download_enabled: ent.phone_download_enabled?,
youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
expires_at: recording.expires_at,
metadata: recording.metadata
}
end
end
end
end

View File

@@ -37,7 +37,11 @@ module Api
}, status: :forbidden }, status: :forbidden
end end
recs = team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(50) recs = team.recordings.not_deleted
.where(status: %w[ready processing])
.includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc)
.limit(50)
render json: recs.map { |r| recording_json(r) } render json: recs.map { |r| recording_json(r) }
end end
@@ -87,7 +91,7 @@ module Api
youtube_uses_platform_channel: yt.uses_platform_channel?, youtube_uses_platform_channel: yt.uses_platform_channel?,
youtube_platform_available: resolver.platform_available?, youtube_platform_available: resolver.platform_available?,
youtube_team_channel_available: resolver.team_channel_available?, youtube_team_channel_available: resolver.team_channel_available?,
youtube_team_channel_title: team.youtube_credential&.channel_title, youtube_team_channel_title: team.club.youtube_credential&.channel_title,
plan_slug: ent.plan.slug, plan_slug: ent.plan.slug,
plan_name: ent.plan.name, plan_name: ent.plan.name,
premium_active: ent.premium_active?, premium_active: ent.premium_active?,
@@ -108,7 +112,7 @@ module Api
youtube_mode: ent.plan.youtube_mode, youtube_mode: ent.plan.youtube_mode,
staff_role: current_user.staff_role_for(team), staff_role: current_user.staff_role_for(team),
can_stream: current_user.can_stream_for?(team), can_stream: current_user.can_stream_for?(team),
can_connect_youtube: current_user.can_stream_for?(team) && ent.premium_full? && ent.youtube_enabled? can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled?
} }
if detail if detail
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut| data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
@@ -121,16 +125,34 @@ module Api
def recording_json(recording) def recording_json(recording)
session = recording.stream_session session = recording.stream_session
match = session.match match = session.match
ent = recording.team.entitlements
{ {
id: recording.id, id: recording.id,
session_id: session.id, session_id: session.id,
match_id: match.id, match_id: match.id,
title: recording.title_or_default,
opponent_name: match.opponent_name, opponent_name: match.opponent_name,
team_name: match.team.name, team_name: match.team.name,
status: recording.status,
status_label: recording.status_label,
privacy_status: recording.privacy_status,
ended_at: session.ended_at, ended_at: session.ended_at,
recorded_at: recording.recorded_at_or_fallback,
duration_secs: recording.duration_secs,
duration_label: recording.duration_label,
byte_size: recording.byte_size,
byte_size_label: recording.byte_size_label,
replay_url: recording.replay_url, replay_url: recording.replay_url,
download_url: recording.replay_url, playback_url: recording.playback_stream_url,
expires_at: recording.expires_at thumbnail_url: recording.thumbnail_url,
download_enabled: ent.phone_download_enabled?,
view_count: recording.view_count,
views_label: recording.views_label,
youtube_video_id: recording.youtube_video_id,
youtube_watch_url: recording.youtube_watch_url,
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
expires_at: recording.expires_at,
metadata: recording.metadata
} }
end end
end end

View File

@@ -4,6 +4,9 @@ module Api
def authorize def authorize
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s } team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
raise ActiveRecord::RecordNotFound unless team raise ActiveRecord::RecordNotFound unless team
unless team.club.owned_by?(current_user)
return render json: { error: "Solo il titolare della società può collegare YouTube" }, status: :forbidden
end
team.entitlements.assert_can_connect_youtube! team.entitlements.assert_can_connect_youtube!
return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app]) return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app])

View File

@@ -0,0 +1,55 @@
module Public
class ClubRecordingsController < WebBaseController
before_action :require_login!
before_action :set_club
before_action -> { require_club_owner!(@club) }
before_action :set_recording, only: %i[update destroy publish_youtube]
def index
@stats = Recordings::ClubStats.new(@club).call
@recordings = Recording.for_club(@club)
.not_deleted
.includes(stream_session: { match: :team })
.order(recorded_at: :desc, created_at: :desc)
@teams = @club.teams.order(:name)
end
def update
privacy = params.require(:recording).permit(:privacy_status, :title)[:privacy_status]
attrs = {}
attrs[:privacy_status] = privacy if privacy.in?(Recording::PRIVACY_STATUSES)
title = params.dig(:recording, :title)
attrs[:title] = title if title.present?
@recording.update!(attrs)
redirect_to public_club_recordings_path(@club), notice: "Replay aggiornato"
rescue ActiveRecord::RecordInvalid => e
redirect_to public_club_recordings_path(@club), alert: e.record.errors.full_messages.join(", ")
end
def destroy
Recordings::Delete.new(@recording).call
redirect_to public_club_recordings_path(@club), notice: "Replay eliminato"
end
def publish_youtube
ent = @recording.team.entitlements
unless ent.premium_full? && ent.youtube_enabled?
redirect_to public_club_recordings_path(@club), alert: "Republicazione YouTube con Premium Full"
return
end
Recordings::PublishToYoutubeJob.perform_async(@recording.id)
redirect_to public_club_recordings_path(@club), notice: "Pubblicazione su YouTube avviata"
end
private
def set_club
@club = current_user.owned_clubs.find(params[:club_id])
end
def set_recording
@recording = Recording.for_club(@club).not_deleted.find(params[:id])
end
end
end

View File

@@ -3,7 +3,7 @@ module Public
include BrandingAttachments include BrandingAttachments
before_action :require_login! before_action :require_login!
before_action :set_club, only: %i[show edit update billing checkout portal] before_action :set_club, only: %i[show edit update billing checkout portal youtube_connect youtube_disconnect]
before_action :require_billing_profile_for_premium_checkout!, only: :checkout before_action :require_billing_profile_for_premium_checkout!, only: :checkout
def new def new
@@ -54,6 +54,7 @@ module Public
@entitlements_team = @club.teams.first @entitlements_team = @club.teams.first
@entitlements = @entitlements_team&.entitlements @entitlements = @entitlements_team&.entitlements
@teams = @club.teams.order(:name) @teams = @club.teams.order(:name)
@replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings?
end end
def edit def edit
@@ -136,6 +137,33 @@ module Public
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}" redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
end end
def youtube_connect
require_club_owner!(@club)
entitlements = @club.teams.first!.entitlements
entitlements.assert_can_connect_youtube!
if ENV["YOUTUBE_CLIENT_ID"].blank?
redirect_to public_club_path(@club), alert: "YouTube OAuth non configurato sul server"
return
end
return_app = params[:return_app] == "1"
state = Youtube::OauthState.for_club(
club_id: @club.id,
user_id: current_user.id,
return_app: return_app
)
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
rescue Teams::EntitlementError => e
redirect_to public_club_path(@club), alert: e.message
end
def youtube_disconnect
require_club_owner!(@club)
@club.youtube_credential&.destroy!
redirect_to public_club_path(@club), notice: "Canale YouTube della società scollegato"
end
def portal def portal
require_club_owner!(@club) require_club_owner!(@club)
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?

View File

@@ -2,14 +2,102 @@ module Public
class ReplayController < SiteBaseController class ReplayController < SiteBaseController
layout "marketing_live" layout "marketing_live"
def show before_action :set_session_and_recording, only: %i[show stream thumbnail download]
@session = StreamSession.includes(match: :team).find(params[:id]) before_action :authorize_replay_access!, only: %i[show stream thumbnail download]
@recording = Recording.ready.find_by(stream_session: @session)
@match = @session.match def index
@query = params[:q].to_s.strip
@recordings = Recording.ready.publicly_listed
.includes(stream_session: { match: { team: :club } })
.search_replays(@query)
.order(recorded_at: :desc, created_at: :desc)
.limit(100)
if params[:club_id].present?
@recordings = @recordings.joins(:team).where(teams: { club_id: params[:club_id] })
end
if params[:team_id].present?
@recordings = @recordings.where(team_id: params[:team_id])
end
@clubs = Club.joins(teams: :recordings)
.merge(Recording.ready.publicly_listed)
.distinct
.order(:name)
@filter_club = @clubs.find { |c| c.id.to_s == params[:club_id].to_s } if params[:club_id].present?
end
def show
@match = @session.match
end
def stream
Recordings::IncrementViews.new(@recording).call
serve_video(disposition: "inline")
end
def thumbnail
serve_thumbnail
end
def download
unless download_allowed?
redirect_to public_replay_path(@session), alert: "Download non disponibile"
return
end
serve_video(disposition: "attachment")
end
private
def serve_video(disposition:)
Recordings::ServeFromStorage.new(
key: @recording.storage_key,
content_type: "video/mp4",
disposition: disposition,
filename: disposition == "attachment" ? download_filename : nil
).call(self)
rescue ArgumentError
head :not_found
end
def serve_thumbnail
key = @recording.thumbnail_storage_key
return head :not_found if key.blank?
Recordings::ServeFromStorage.new(
key: key,
content_type: "image/jpeg",
disposition: "inline"
).call(self)
rescue ArgumentError
head :not_found
end
def download_allowed?
ent = @recording.team.entitlements
return false unless ent.phone_download_enabled?
Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed? ||
Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
end
def download_filename
slug = @recording.title_or_default.parameterize.presence || "replay"
"#{slug}.mp4"
end
def set_session_and_recording
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
@recording = Recording.not_deleted.find_by(stream_session: @session)
return if @recording
unless @recording
redirect_to public_live_path(@session), alert: "Replay non disponibile" redirect_to public_live_path(@session), alert: "Replay non disponibile"
end end
def authorize_replay_access!
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed?
redirect_to public_live_index_path, alert: "Replay non disponibile"
end end
end end
end end

View File

@@ -116,40 +116,20 @@ module Public
end end
def youtube_connect def youtube_connect
require_youtube_connect_access!(@team) redirect_to public_club_youtube_connect_path(
@team.entitlements.assert_can_connect_youtube! @team.club_id,
return_app: params[:return_app]
if ENV["YOUTUBE_CLIENT_ID"].blank?
redirect_to public_team_details_path(@team), alert: "YouTube OAuth non configurato sul server"
return
end
return_app = params[:return_app] == "1"
state = Youtube::OauthState.for_team(
team_id: @team.id,
user_id: current_user.id,
return_app: return_app
) )
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
rescue Teams::EntitlementError => e
redirect_to public_team_details_path(@team), alert: e.message
end end
def youtube_disconnect def youtube_disconnect
require_club_owner_for_team!(@team) require_club_owner_for_team!(@team)
@team.youtube_credential&.destroy! @team.club.youtube_credential&.destroy!
redirect_to public_team_details_path(@team), notice: "Canale YouTube scollegato" redirect_to public_club_path(@team.club), notice: "Canale YouTube della società scollegato"
end end
private private
def require_youtube_connect_access!(team)
return if team.club&.owned_by?(current_user)
return if current_user.can_stream_for?(team)
redirect_to public_team_details_path(team), alert: "Accesso non consentito"
end
def load_team_details! def load_team_details!
@club = @team.club @club = @team.club
@can_manage = @club.owned_by?(current_user) @can_manage = @club.owned_by?(current_user)
@@ -160,7 +140,11 @@ module Public
category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player" category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player"
) )
end end
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10) @recordings = @team.recordings.not_deleted
.where(status: %w[ready processing])
.includes(stream_session: :match)
.order(recorded_at: :desc, created_at: :desc)
.limit(10)
@owner_membership = current_user.user_teams.find_by(team: @team) @owner_membership = current_user.user_teams.find_by(team: @team)
@staff_memberships = @team.user_teams.includes(:user) @staff_memberships = @team.user_teams.includes(:user)
.where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)") .where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)")

View File

@@ -13,12 +13,12 @@ class YoutubeOauthCallbackController < ActionController::Base
end end
user = User.find(ctx[:user_id]) user = User.find(ctx[:user_id])
team = Team.find(ctx[:team_id]) club = Club.find(ctx[:club_id])
unless user.can_stream_for?(team) || team.club&.owned_by?(user) unless club.owned_by?(user)
return redirect_to_oauth_error("Non autorizzato a collegare YouTube per questa squadra") return redirect_to_oauth_error("Solo il titolare della società può collegare il canale YouTube")
end end
cred = team.youtube_credential || team.build_youtube_credential cred = club.youtube_credential || club.build_youtube_credential
cred.update!( cred.update!(
access_token: tokens[:access_token], access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token] || cred.refresh_token, refresh_token: tokens[:refresh_token] || cred.refresh_token,
@@ -28,9 +28,9 @@ class YoutubeOauthCallbackController < ActionController::Base
) )
if ctx[:return_app] if ctx[:return_app]
redirect_to "matchlivetv://youtube-connected?team_id=#{team.id}", allow_other_host: true redirect_to "matchlivetv://youtube-connected?club_id=#{club.id}", allow_other_host: true
else else
redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true redirect_to "#{public_base_url}/clubs/#{club.id}?youtube=connected", allow_other_host: true
end end
rescue ArgumentError, Youtube::BroadcastService::Error => e rescue ArgumentError, Youtube::BroadcastService::Error => e
redirect_to_oauth_error(e.message) redirect_to_oauth_error(e.message)

View File

@@ -0,0 +1,18 @@
module Recordings
class ExpiryWarningJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
Recording.ready
.where(expiry_warning_sent_at: nil)
.where.not(expires_at: nil)
.where(expires_at: ..7.days.from_now)
.find_each do |recording|
Recordings::NotifyExpiring.new(recording).call
rescue StandardError => e
Rails.logger.warn("[Recordings::ExpiryWarningJob] #{recording.id}: #{e.message}")
end
end
end
end

View File

@@ -0,0 +1,18 @@
module Recordings
class PostProcessJob
include Sidekiq::Job
sidekiq_options retry: 2, queue: "default"
def perform(recording_id)
recording = Recording.find_by(id: recording_id)
return unless recording&.ready?
Recordings::NotifyReady.new(recording).call
return unless recording.auto_publish_youtube?
return if recording.youtube_video_id.present?
Recordings::PublishToYoutubeJob.perform_async(recording.id)
end
end
end

View File

@@ -0,0 +1,17 @@
module Recordings
class PublishToYoutubeJob
include Sidekiq::Job
sidekiq_options retry: 2, queue: "default"
def perform(recording_id)
recording = Recording.find_by(id: recording_id)
return unless recording&.ready?
Recordings::PublishToYoutube.new(recording).call
rescue Recordings::PublishToYoutube::Error => e
meta = recording.metadata.merge("youtube_publish_error" => e.message, "youtube_publish_failed_at" => Time.current.iso8601)
recording.update!(metadata: meta)
Rails.logger.warn("[Recordings::PublishToYoutubeJob] #{recording_id}: #{e.message}")
end
end
end

View File

@@ -0,0 +1,14 @@
module Recordings
class PurgeExpiredJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
def perform
Recording.expired_pending_purge.find_each do |recording|
Recordings::Delete.new(recording, reason: :expired).call
rescue StandardError => e
Rails.logger.warn("[Recordings::PurgeExpiredJob] #{recording.id}: #{e.message}")
end
end
end
end

View File

@@ -0,0 +1,14 @@
module Recordings
class UploadJob
include Sidekiq::Job
sidekiq_options retry: 3, queue: "default"
def perform(session_id)
session = StreamSession.find_by(id: session_id)
return unless session
Recordings::UploadFromSession.new(session).call
Recordings::PurgeExpiredJob.perform_async
end
end
end

View File

@@ -0,0 +1,34 @@
module Recordings
class ReplayMailer < ApplicationMailer
default from: -> { MatchLiveTv.mail_from }
def replay_ready(recording:, recipient:)
@recording = recording
@team = recording.team
@club = @team.club
@recipient = recipient
@replay_url = recording.replay_url
@expires_at = recording.expires_at
mail(
to: recipient.email,
subject: "Replay pronto — #{recording.title_or_default}"
)
end
def replay_expiring_soon(recording:, recipient:)
@recording = recording
@team = recording.team
@club = @team.club
@recipient = recipient
@replay_url = recording.replay_url
@expires_at = recording.expires_at
@days_left = ((recording.expires_at - Time.current) / 1.day).ceil
mail(
to: recipient.email,
subject: "Replay in scadenza tra #{@days_left} giorni — #{recording.title_or_default}"
)
end
end
end

View File

@@ -5,6 +5,7 @@ class Club < ApplicationRecord
has_many :club_memberships, dependent: :destroy has_many :club_memberships, dependent: :destroy
has_many :users, through: :club_memberships has_many :users, through: :club_memberships
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy has_one :subscription, dependent: :destroy
has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy
has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy

View File

@@ -1,21 +1,160 @@
class Recording < ApplicationRecord class Recording < ApplicationRecord
STATUSES = %w[processing ready expired].freeze STATUSES = %w[processing ready expired failed].freeze
PRIVACY_STATUSES = %w[public unlisted].freeze
STORAGE_BACKENDS = %w[local s3].freeze
belongs_to :stream_session belongs_to :stream_session
belongs_to :team belongs_to :team
validates :status, inclusion: { in: STATUSES } validates :status, inclusion: { in: STATUSES }
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
scope :ready, -> { where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current) } scope :not_deleted, -> { where(deleted_at: nil) }
scope :ready, lambda {
not_deleted.where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current)
}
scope :publicly_listed, -> { where(privacy_status: "public") }
scope :for_team, ->(team) { where(team: team) } scope :for_team, ->(team) { where(team: team) }
scope :for_club, lambda { |club|
joins(:team).where(teams: { club_id: club.id })
}
scope :expiring_within, lambda { |days|
ready.where(expires_at: ..days.days.from_now)
}
scope :expired_pending_purge, lambda {
not_deleted.where(status: %w[ready failed]).where("expires_at IS NOT NULL AND expires_at <= ?", Time.current)
}
scope :search_replays, lambda { |query|
q = query.to_s.strip
return all if q.blank?
term = "%#{sanitize_sql_like(q)}%"
joins(stream_session: { match: { team: :club } }).where(
"recordings.title ILIKE :term OR teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
term: term
)
}
before_validation :normalize_privacy_status
def replay_url def replay_url
return nil unless ready? && stream_session_id.present? return nil unless stream_session_id.present?
return nil unless ready? || status == "processing"
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}" "#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}"
end end
def playback_stream_url
return nil unless ready? && stream_session_id.present?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream"
end
def thumbnail_url
return nil unless thumbnail_storage_key.present? && stream_session_id.present?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
end
def download_api_path
return nil unless ready?
"/api/v1/recordings/#{id}/download"
end
def youtube_watch_url
return nil if youtube_video_id.blank?
return nil if youtube_video_id.to_s.start_with?("mock_")
"https://www.youtube.com/watch?v=#{youtube_video_id}"
end
def ready? def ready?
status == "ready" && (expires_at.nil? || expires_at.future?) status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
end
def deleted?
deleted_at.present?
end
def publicly_listed?
privacy_status == "public"
end
def unlisted?
privacy_status == "unlisted"
end
def auto_publish_youtube?
metadata.is_a?(Hash) && metadata["auto_publish_youtube"] == true
end
def ai_metadata
metadata.fetch("ai", {})
end
def merge_metadata!(attrs)
update!(metadata: metadata.merge(attrs.stringify_keys))
end
def title_or_default
title.presence || default_title
end
def default_title
match = stream_session&.match
return "Replay" unless match
"#{match.team.name} vs #{match.opponent_name}"
end
def recorded_at_or_fallback
recorded_at || stream_session&.ended_at || stream_session&.started_at || created_at
end
def duration_label
return "" if duration_secs.to_i <= 0
mins = duration_secs / 60
secs = duration_secs % 60
format("%d:%02d", mins, secs)
end
def byte_size_label
bytes = byte_size.to_i
return "" if bytes <= 0
if bytes >= 1.gigabyte
format("%.1f GB", bytes / 1.gigabyte.to_f)
elsif bytes >= 1.megabyte
format("%.1f MB", bytes / 1.megabyte.to_f)
else
format("%.0f KB", bytes / 1.kilobyte.to_f)
end
end
def views_label
count = view_count.to_i
return "0 visualizzazioni" if count.zero?
count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni"
end
def status_label
return "Eliminato" if deleted?
return "Scaduto" if status == "expired"
return "Errore" if status == "failed"
return "In elaborazione" if status == "processing"
return "Scade presto" if expires_at.present? && expires_at <= 7.days.from_now
"Disponibile"
end
private
def normalize_privacy_status
self.privacy_status = "public" if privacy_status == "private"
self.privacy_status = "unlisted" if privacy_status.blank?
end end
end end

View File

@@ -5,7 +5,6 @@ class Team < ApplicationRecord
has_many :user_teams, dependent: :destroy has_many :user_teams, dependent: :destroy
has_many :users, through: :user_teams has_many :users, through: :user_teams
has_many :matches, dependent: :destroy has_many :matches, dependent: :destroy
has_one :youtube_credential, dependent: :destroy
has_many :recordings, dependent: :destroy has_many :recordings, dependent: :destroy
has_many :team_invitations, dependent: :destroy has_many :team_invitations, dependent: :destroy
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
@@ -23,6 +22,11 @@ class Team < ApplicationRecord
Teams::Entitlements.new(self) Teams::Entitlements.new(self)
end end
# Canale YouTube OAuth è unico per tutta la società (Premium Full).
def youtube_credential
club&.youtube_credential
end
def owner def owner
club&.owner club&.owner
end end

View File

@@ -1,5 +1,5 @@
class YoutubeCredential < ApplicationRecord class YoutubeCredential < ApplicationRecord
belongs_to :team belongs_to :club
attr_encrypted :access_token, attr_encrypted :access_token,
key: :encryption_key, key: :encryption_key,

View File

@@ -19,8 +19,10 @@ module Admin
teams_count: Team.count, teams_count: Team.count,
users_count: User.count, users_count: User.count,
matches_count: Match.count, matches_count: Match.count,
recordings_count: Recording.count, recordings_count: Recording.not_deleted.count,
recordings_ready: Recording.where(status: "ready").count recordings_ready: Recording.ready.count,
recordings_bytes: Recording.ready.sum(:byte_size).to_i,
recordings_expiring_soon: Recording.ready.expiring_within(7).count,
} }
end end
end end

View File

@@ -25,6 +25,10 @@ module Mediamtx
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S", recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
recordSegmentDuration: "60s" recordSegmentDuration: "60s"
} }
if record
retention_hours = session.match.team.entitlements.recording_retention_days * 24 + 48
body[:recordDeleteAfter] = "#{retention_hours}h"
end
unless session.matchlivetv_platform? unless session.matchlivetv_platform?
body[:runOnReady] = run_on_ready_script(session) body[:runOnReady] = run_on_ready_script(session)
body[:runOnReadyRestart] = true body[:runOnReadyRestart] = true

View File

@@ -0,0 +1,33 @@
module Recordings
class AccessPolicy
def initialize(recording, viewer: nil)
@recording = recording
@viewer = viewer
end
def allowed?
return false if @recording.deleted?
return true if @recording.status == "processing" && (club_member? || manage_allowed?)
return false unless @recording.ready?
return true if @recording.unlisted? || @recording.publicly_listed?
club_member?
end
def manage_allowed?
return false unless @viewer
team = @recording.team
team.club.owned_by?(@viewer) || @viewer.manageable_teams.exists?(id: team.id)
end
private
def club_member?
return false unless @viewer
@recording.team.club.users.exists?(id: @viewer.id)
end
end
end

View File

@@ -0,0 +1,20 @@
module Recordings
class ClubStats
def initialize(club)
@club = club
end
def call
scope = Recording.for_club(@club).not_deleted
ready = scope.ready
{
available_count: ready.count,
expiring_soon_count: ready.expiring_within(7).count,
total_bytes: ready.sum(:byte_size).to_i,
total_views: ready.sum(:view_count).to_i,
processing_count: scope.where(status: "processing").count,
failed_count: scope.where(status: "failed").count
}
end
end
end

View File

@@ -0,0 +1,38 @@
module Recordings
class Delete
def initialize(recording, reason: :manual)
@recording = recording
@reason = reason
end
def call
delete_storage_object if @recording.storage_key.present?
cleanup_local_artifacts
@recording.update!(
status: "expired",
deleted_at: Time.current,
storage_key: nil,
thumbnail_storage_key: nil
)
@recording
end
private
def delete_storage_object
storage = Recordings::Storage.new
storage.delete(key: @recording.storage_key) if @recording.storage_key.present?
storage.delete(key: @recording.thumbnail_storage_key) if @recording.thumbnail_storage_key.present?
rescue Recordings::Storage::Error => e
Rails.logger.warn("[Recordings::Delete] storage delete failed: #{e.message}")
end
def cleanup_local_artifacts
return if @recording.storage_path.blank?
base = File.join(MatchLiveTv.recordings_local_path, @recording.storage_path)
FileUtils.rm_rf(base) if Dir.exist?(base)
end
end
end

View File

@@ -0,0 +1,28 @@
module Recordings
class DownloadUrl
class NotAllowed < StandardError; end
def initialize(recording, viewer: nil)
@recording = recording
@viewer = viewer
end
def call
raise NotAllowed, "Download non disponibile per questo piano" unless download_allowed?
raise ArgumentError, "Replay non pronto" unless @recording.ready?
raise ArgumentError, "File non disponibile" if @recording.storage_key.blank?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{@recording.stream_session_id}/download"
end
private
def download_allowed?
ent = @recording.team.entitlements
return false unless ent.phone_download_enabled?
policy = Recordings::AccessPolicy.new(@recording, viewer: @viewer)
policy.allowed? || policy.manage_allowed?
end
end
end

View File

@@ -0,0 +1,51 @@
module Recordings
class FinalizeSession
def initialize(session)
@session = session
end
def call
team = @session.match.team
return unless team.entitlements.can_access_recordings?
retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
recording = Recording.find_or_initialize_by(stream_session: @session)
recording.assign_attributes(
team: team,
status: "processing",
title: default_title,
privacy_status: privacy_from_session,
storage_path: @session.mediamtx_path_name,
recorded_at: @session.ended_at || Time.current,
expires_at: expires_at,
error_message: nil,
metadata: initial_metadata(team)
)
recording.save!
recording
end
private
def default_title
match = @session.match
"#{match.team.name} vs #{match.opponent_name}"
end
def privacy_from_session
@session.privacy_status == "public" ? "public" : "unlisted"
end
def initial_metadata(team)
ent = team.entitlements
{
"source_platform" => @session.platform,
"session_privacy" => @session.privacy_status,
"auto_publish_youtube" => ent.premium_full? && ent.youtube_enabled? && @session.platform == "youtube",
"ai" => {}
}
end
end
end

View File

@@ -0,0 +1,55 @@
module Recordings
class GenerateThumbnail
class Error < StandardError; end
def initialize(recording, video_path: nil)
@recording = recording
@video_path = video_path
end
def call
return @recording if @recording.thumbnail_storage_key.present?
path = @video_path || local_video_path
return @recording unless path && File.exist?(path)
thumb_path = extract_frame(path)
key = thumbnail_key
Recordings::Storage.new.upload(local_path: thumb_path, key: key, content_type: "image/jpeg")
@recording.update!(thumbnail_storage_key: key)
FileUtils.rm_f(thumb_path)
@recording
rescue Recordings::Storage::Error => e
Rails.logger.warn("[Recordings::GenerateThumbnail] #{@recording.id}: #{e.message}")
@recording
ensure
FileUtils.rm_f(@thumb_temp) if @thumb_temp && File.exist?(@thumb_temp)
end
private
def local_video_path
return nil if @recording.storage_key.blank?
Recordings::Storage.new.local_path_for_key(@recording.storage_key)
end
def extract_frame(video_path)
@thumb_temp = File.join(Dir.tmpdir, "thumb-#{@recording.id}-#{SecureRandom.hex(4)}.jpg")
dur = @recording.duration_secs.to_i
offset = dur.positive? ? [[dur / 4, 5].max, dur - 1].min : 1
success = system(
"ffmpeg", "-y", "-ss", offset.to_s, "-i", video_path,
"-vframes", "1", "-q:v", "2", @thumb_temp,
out: File::NULL, err: File::NULL
)
raise Error, "ffmpeg thumbnail failed" unless success && File.exist?(@thumb_temp)
@thumb_temp
end
def thumbnail_key
"teams/#{@recording.team_id}/sessions/#{@recording.stream_session_id}/thumbnail.jpg"
end
end
end

View File

@@ -0,0 +1,14 @@
module Recordings
class IncrementViews
def initialize(recording)
@recording = recording
end
def call
return unless @recording.ready?
Recording.where(id: @recording.id).update_all("view_count = view_count + 1")
@recording.reload
end
end
end

View File

@@ -1,25 +1,12 @@
module Recordings module Recordings
# @deprecated Use Recordings::FinalizeSession
class IndexSession class IndexSession
def initialize(session) def initialize(session)
@session = session @session = session
end end
def call def call
team = @session.match.team FinalizeSession.new(@session).call
return unless team.entitlements.can_access_recordings?
retention_days = team.entitlements.recording_retention_days
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
recording = Recording.find_or_initialize_by(stream_session: @session)
recording.assign_attributes(
team: team,
status: "ready",
storage_path: @session.mediamtx_path_name,
expires_at: expires_at
)
recording.save!
recording
end end
end end
end end

View File

@@ -0,0 +1,33 @@
module Recordings
class NotifyExpiring
WINDOW_DAYS = 7
def initialize(recording)
@recording = recording
end
def call
return @recording if @recording.expiry_warning_sent_at.present?
return @recording unless @recording.ready?
return @recording if @recording.expires_at.blank?
return @recording unless @recording.expires_at <= WINDOW_DAYS.days.from_now
recipients.each do |user|
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now
end
@recording.update!(expiry_warning_sent_at: Time.current)
@recording
end
private
def recipients
club = @recording.team.club
owner = club.owner
users = club.users.distinct.to_a
users << owner if owner && users.none? { |u| u.id == owner.id }
users.uniq.select { |u| u.email.present? }
end
end
end

View File

@@ -0,0 +1,29 @@
module Recordings
class NotifyReady
def initialize(recording)
@recording = recording
end
def call
return @recording if @recording.ready_notified_at.present?
return @recording unless @recording.ready?
recipients.each do |user|
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now
end
@recording.update!(ready_notified_at: Time.current)
@recording
end
private
def recipients
club = @recording.team.club
owner = club.owner
users = club.users.distinct.to_a
users << owner if owner && users.none? { |u| u.id == owner.id }
users.uniq.select { |u| u.email.present? }
end
end
end

View File

@@ -0,0 +1,20 @@
module Recordings
class PlaybackUrl
def initialize(recording)
@recording = recording
end
def call
raise ArgumentError, "Replay non pronto" unless @recording.ready?
raise ArgumentError, "Storage key mancante" if @recording.storage_key.blank?
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{@recording.stream_session_id}/stream"
end
def local_path
storage = Recordings::Storage.new
path = storage.local_path_for_key(@recording.storage_key)
path if path && File.exist?(path)
end
end
end

View File

@@ -0,0 +1,79 @@
module Recordings
class PublishToYoutube
class Error < StandardError; end
def initialize(recording, privacy_status: nil)
@recording = recording
@privacy_status = privacy_status
end
def call
team = @recording.team
ent = team.entitlements
raise Error, "Republicazione YouTube disponibile con Premium Full" unless ent.premium_full? && ent.youtube_enabled?
raise Error, "Replay già pubblicato su YouTube" if @recording.youtube_video_id.present?
raise Error, "Replay non pronto" unless @recording.ready?
video_path = local_video_path
raise Error, "File video non disponibile in locale; riprova dopo lupload" unless video_path
session = @recording.stream_session
privacy = @privacy_status.presence || youtube_privacy
channel = session.platform == "youtube" ? "team" : nil
result = Youtube::VideoUploadService.new(team, youtube_channel: channel).upload_file!(
file_path: video_path,
title: @recording.title_or_default,
description: youtube_description(session),
privacy_status: privacy,
tags: %w[MatchLiveTV replay sport]
)
meta = @recording.metadata.merge(
"youtube_publish" => {
"video_id" => result[:video_id],
"watch_url" => result[:watch_url],
"published_at" => Time.current.iso8601,
"privacy_status" => privacy
}
)
@recording.update!(
youtube_video_id: result[:video_id],
youtube_published_at: Time.current,
metadata: meta
)
@recording
ensure
FileUtils.rm_f(@temp_video) if @temp_video && File.exist?(@temp_video)
end
private
def local_video_path
return @temp_video if @temp_video && File.exist?(@temp_video)
if @recording.storage_key.blank?
return nil
end
storage = Recordings::Storage.new
path = storage.local_path_for_key(@recording.storage_key)
return path if path && File.exist?(path)
@temp_video = storage.download_to_temp(key: @recording.storage_key)
@temp_video
end
def youtube_privacy
@recording.publicly_listed? ? "public" : "unlisted"
end
def youtube_description(session)
match = session.match
base = "#{match.team.name} vs #{match.opponent_name}"
url = @recording.replay_url
[base, url, "Trasmesso con Match Live TV"].compact.join("\n\n")
end
end
end

View File

@@ -0,0 +1,74 @@
# frozen_string_literal: true
require "aws-sdk-s3"
module Recordings
# Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900).
class ServeFromStorage
def initialize(key:, content_type:, disposition: "inline", filename: nil)
@key = key
@content_type = content_type
@disposition = disposition
@filename = filename
end
def call(controller)
path = local_path
return controller.send_file(path, type: @content_type, disposition: @disposition, filename: @filename) if path
proxy_s3(controller)
end
def local_path
path = Recordings::Storage.new.local_path_for_key(@key)
path if path && File.exist?(path)
end
private
def proxy_s3(controller)
raise ArgumentError, "Storage key mancante" if @key.blank?
raise ArgumentError, "Storage S3 non configurato" if MatchLiveTv.replay_storage_local?
range = controller.request.headers["Range"]
resp = s3_client.get_object(
bucket: MatchLiveTv.replay_storage_bucket,
key: @key,
range: range.presence
)
controller.response.headers["Content-Type"] = @content_type
controller.response.headers["Accept-Ranges"] = "bytes"
controller.response.headers["Content-Length"] = resp.content_length.to_s if resp.content_length
controller.response.headers["Content-Range"] = resp.content_range if resp.content_range.present?
data = resp.body.read
max = 1.gigabyte
raise ArgumentError, "File troppo grande per il proxy" if data.bytesize > max
if @disposition == "attachment" && @filename.present?
controller.send_data(
data,
type: @content_type,
disposition: "attachment",
filename: @filename
)
else
controller.send_data(data, type: @content_type, disposition: "inline")
end
controller.status = :partial_content if range.present? && resp.content_range.present?
end
def s3_client
@s3_client ||= Aws::S3::Client.new(
access_key_id: MatchLiveTv.replay_storage_access_key_id,
secret_access_key: MatchLiveTv.replay_storage_secret_access_key,
endpoint: MatchLiveTv.replay_storage_endpoint,
region: MatchLiveTv.replay_storage_region,
force_path_style: MatchLiveTv.replay_storage_force_path_style?,
# Garage non sempre allinea i checksum AWS su Range/read parziali
response_checksum_validation: "WHEN_REQUIRED"
)
end
end
end

View File

@@ -0,0 +1,123 @@
require "open-uri"
require "aws-sdk-s3"
module Recordings
class Storage
class Error < StandardError; end
def initialize
@backend = MatchLiveTv.replay_storage_local? ? LocalBackend.new : S3Backend.new
end
def upload(local_path:, key:, content_type: "video/mp4")
@backend.upload(local_path: local_path, key: key, content_type: content_type)
end
def delete(key:)
@backend.delete(key: key)
end
def download_to_temp(key:)
local = local_path_for_key(key)
return local if local && File.exist?(local)
url = presigned_get_url(key: key, expires_in: 600)
raise Error, "Impossibile scaricare file dallo storage" if url.blank?
dest = File.join(Dir.tmpdir, "replay-dl-#{SecureRandom.hex(8)}.mp4")
URI.open(url) do |remote|
File.binwrite(dest, remote.read)
end
dest
end
def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds)
@backend.presigned_get_url(key: key, expires_in: expires_in)
end
def local_path_for_key(key)
@backend.local_path_for_key(key)
end
def backend_name
@backend.name
end
class LocalBackend
def name
"local"
end
def upload(local_path:, key:, content_type:)
dest = local_path_for_key(key)
FileUtils.mkdir_p(File.dirname(dest))
FileUtils.cp(local_path, dest)
File.size(dest)
end
def delete(key:)
path = local_path_for_key(key)
FileUtils.rm_f(path)
true
end
def presigned_get_url(key:, expires_in:)
nil
end
def local_path_for_key(key)
File.join(MatchLiveTv.recordings_local_path, "replays", key)
end
end
class S3Backend
def name
"s3"
end
def upload(local_path:, key:, content_type:)
client.put_object(
bucket: MatchLiveTv.replay_storage_bucket,
key: key,
body: File.open(local_path, "rb"),
content_type: content_type
)
File.size(local_path)
end
def delete(key:)
client.delete_object(bucket: MatchLiveTv.replay_storage_bucket, key: key)
true
rescue Aws::S3::Errors::ServiceError => e
raise Error, e.message
end
def presigned_get_url(key:, expires_in:)
signer = Aws::S3::Presigner.new(client: client)
signer.presigned_url(
:get_object,
bucket: MatchLiveTv.replay_storage_bucket,
key: key,
expires_in: expires_in
)
end
def local_path_for_key(_key)
nil
end
private
def client
@client ||= Aws::S3::Client.new(
access_key_id: MatchLiveTv.replay_storage_access_key_id,
secret_access_key: MatchLiveTv.replay_storage_secret_access_key,
endpoint: MatchLiveTv.replay_storage_endpoint,
region: MatchLiveTv.replay_storage_region,
force_path_style: MatchLiveTv.replay_storage_force_path_style?,
response_checksum_validation: "WHEN_REQUIRED"
)
end
end
end
end

View File

@@ -0,0 +1,104 @@
module Recordings
class UploadFromSession
class Error < StandardError; end
def initialize(session)
@session = session
end
def call
recording = Recording.find_by(stream_session: @session)
return unless recording&.status == "processing"
source_files = local_source_files
if source_files.empty?
fail_recording!(recording, "Nessun file di registrazione trovato")
cleanup_mediamtx_path
return recording
end
merged_path = merge_source_files(source_files)
duration_secs = probe_duration(merged_path)
storage_key = object_key(recording)
storage = Recordings::Storage.new
byte_size = storage.upload(local_path: merged_path, key: storage_key)
Recordings::GenerateThumbnail.new(recording, video_path: merged_path).call
recording.update!(
status: "ready",
storage_key: storage_key,
storage_bucket: MatchLiveTv.replay_storage_bucket,
storage_backend: storage.backend_name,
byte_size: byte_size,
duration_secs: duration_secs,
error_message: nil
)
cleanup_local_sources(source_files, merged_path)
cleanup_mediamtx_path
Recordings::PostProcessJob.perform_async(recording.id)
recording
rescue Error, Recordings::Storage::Error => e
recording = Recording.find_by(stream_session: @session)
fail_recording!(recording, e.message) if recording
raise
ensure
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
end
private
def local_source_files
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
return [] unless Dir.exist?(base)
Dir.glob(File.join(base, "**", "*"))
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
.sort_by { |path| File.basename(path) }
end
def merge_source_files(source_files)
return source_files.first if source_files.one?
@merged_temp_path = File.join(Dir.tmpdir, "replay-#{@session.id}-#{SecureRandom.hex(4)}.mp4")
list_path = "#{@merged_temp_path}.txt"
File.write(list_path, source_files.map { |f| "file '#{f.gsub("'", "'\\''")}'" }.join("\n"))
success = system("ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", @merged_temp_path,
out: File::NULL, err: File::NULL)
FileUtils.rm_f(list_path)
raise Error, "Impossibile unire i segmenti video" unless success && File.exist?(@merged_temp_path)
@merged_temp_path
end
def probe_duration(path)
output = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "#{path}" 2>/dev/null`
output.to_f.round
rescue StandardError
@session.total_duration_secs.to_i
end
def object_key(recording)
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
end
def cleanup_local_sources(source_files, merged_path)
source_files.each { |path| FileUtils.rm_f(path) }
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
FileUtils.rm_rf(base) if Dir.exist?(base)
FileUtils.rm_f(merged_path) if merged_path != source_files.first
end
def cleanup_mediamtx_path
Mediamtx::Client.new.delete_path(@session)
rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
end
def fail_recording!(recording, message)
recording.update!(status: "failed", error_message: message)
cleanup_mediamtx_path
end
end
end

View File

@@ -9,8 +9,14 @@ module Sessions
Sessions::RegiaAccess.revoke!(@session) Sessions::RegiaAccess.revoke!(@session)
@session.end_stream! @session.end_stream!
complete_youtube_broadcast! if @session.youtube_broadcast_id.present? complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
Recordings::IndexSession.new(@session).call
recording = Recordings::FinalizeSession.new(@session).call
if recording
Recordings::UploadJob.perform_async(@session.id)
else
Mediamtx::Client.new.delete_path(@session) Mediamtx::Client.new.delete_path(@session)
end
log_event("ended") log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" }) SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session @session

View File

@@ -19,7 +19,7 @@ module Youtube
when :platform when :platform
PlatformCredential.configured? ? PlatformCredential.new : nil PlatformCredential.configured? ? PlatformCredential.new : nil
when :team when :team
@team.youtube_credential @team.club.youtube_credential
end end
end end
@@ -32,7 +32,7 @@ module Youtube
end end
def team_channel_available? def team_channel_available?
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.youtube_credential.present? @ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.club.youtube_credential.present?
end end
def effective_channel def effective_channel

View File

@@ -3,12 +3,18 @@ module Youtube
PURPOSE = :youtube_oauth_connect PURPOSE = :youtube_oauth_connect
class << self class << self
def for_team(team_id:, user_id:, return_app: false) def for_club(club_id:, user_id:, return_app: false)
payload = [team_id.to_s, user_id.to_s] payload = ["club", club_id.to_s, user_id.to_s]
payload << "app" if return_app payload << "app" if return_app
verifier.generate(payload, expires_in: 1.hour) verifier.generate(payload, expires_in: 1.hour)
end end
# API mobile: accetta team_id ma firma OAuth per la società.
def for_team(team_id:, user_id:, return_app: false)
club_id = Team.find(team_id).club_id
for_club(club_id: club_id, user_id: user_id, return_app: return_app)
end
def for_platform(admin_id:) def for_platform(admin_id:)
verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour) verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour)
end end
@@ -19,10 +25,21 @@ module Youtube
when Array when Array
if payload.first == "platform" if payload.first == "platform"
{ kind: :platform, admin_id: payload[1] } { kind: :platform, admin_id: payload[1] }
elsif payload.size >= 2 elsif payload.first == "club" && payload.size >= 3
{ {
kind: :team, kind: :club,
team_id: payload[0], club_id: payload[1],
user_id: payload[2],
return_app: payload[3] == "app"
}
elsif payload.size >= 2
# Stato legacy (team_id): risolvi la società
team = Team.find_by(id: payload[0])
raise ArgumentError, "stato OAuth non valido" unless team
{
kind: :club,
club_id: team.club_id,
user_id: payload[1], user_id: payload[1],
return_app: payload[2] == "app" return_app: payload[2] == "app"
} }

View File

@@ -15,7 +15,7 @@ module Youtube
when "matchlivetv_light" when "matchlivetv_light"
PlatformCredential.configured? PlatformCredential.configured?
when "team" when "team"
@team.youtube_credential.present? || PlatformCredential.configured? @team.club.youtube_credential.present? || PlatformCredential.configured?
else else
false false
end end
@@ -31,7 +31,7 @@ module Youtube
if @mode == "matchlivetv_light" if @mode == "matchlivetv_light"
PlatformCredential.configured? PlatformCredential.configured?
elsif @mode == "team" elsif @mode == "team"
@team.youtube_credential.blank? && PlatformCredential.configured? @team.club.youtube_credential.blank? && PlatformCredential.configured?
else else
false false
end end
@@ -41,12 +41,12 @@ module Youtube
if uses_platform_channel? if uses_platform_channel?
PLATFORM_LABEL PLATFORM_LABEL
else else
@team.youtube_credential&.channel_title @team.club.youtube_credential&.channel_title
end end
end end
def needs_team_oauth? def needs_team_oauth?
@mode == "team" && @ent.premium_full? && @team.youtube_credential.blank? && !PlatformCredential.configured? @mode == "team" && @ent.premium_full? && @team.club.youtube_credential.blank? && !PlatformCredential.configured?
end end
end end
end end

View File

@@ -0,0 +1,57 @@
module Youtube
class VideoUploadService
class Error < StandardError; end
def initialize(team, youtube_channel: nil)
@team = team
@resolver = CredentialResolver.new(team, channel: youtube_channel)
@credential = @resolver.resolve
end
def upload_file!(file_path:, title:, description: nil, privacy_status: "unlisted", tags: [])
return mock_upload if @credential.blank? || missing_oauth_config?
raise Error, "File non trovato" unless File.exist?(file_path)
client = authorized_client
video = Google::Apis::YoutubeV3::Video.new(
snippet: Google::Apis::YoutubeV3::VideoSnippet.new(
title: title,
description: description,
tags: tags.presence
),
status: Google::Apis::YoutubeV3::VideoStatus.new(
privacy_status: privacy_status,
self_declared_made_for_kids: false
)
)
result = client.insert_video(
"snippet,status",
video,
upload_source: file_path,
content_type: "video/mp4"
)
{
video_id: result.id,
watch_url: "https://www.youtube.com/watch?v=#{result.id}"
}
rescue Google::Apis::Error => e
raise Error, e.message
end
private
def mock_upload
id = "mock_vod_#{SecureRandom.hex(6)}"
{ video_id: id, watch_url: "https://www.youtube.com/watch?v=#{id}" }
end
def missing_oauth_config?
ENV["YOUTUBE_CLIENT_ID"].blank?
end
def authorized_client
OauthRefresh.new(@credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
end
end
end

View File

@@ -9,6 +9,18 @@
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %> <%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
<% cred = @club.youtube_credential %>
<p style="margin-top:16px">
<strong>YouTube società:</strong>
<% if cred %>
<%= cred.channel_title.presence || cred.channel_id %> (collegato)
<% elsif @teams.any? { |t| t.entitlements.premium_full? } %>
<span class="muted">Premium Full — canale società non collegato (usa Match Live TV)</span>
<% else %>
<span class="muted">—</span>
<% end %>
</p>
<h3 style="font-size:1rem;margin-top:28px">Squadre</h3> <h3 style="font-size:1rem;margin-top:28px">Squadre</h3>
<% if @teams.empty? %> <% if @teams.empty? %>
<p class="muted">Nessuna squadra registrata.</p> <p class="muted">Nessuna squadra registrata.</p>
@@ -18,32 +30,14 @@
<tr> <tr>
<th>Squadra</th> <th>Squadra</th>
<th>Sport</th> <th>Sport</th>
<th>YouTube</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% @teams.each do |team| %> <% @teams.each do |team| %>
<% yt = Youtube::TeamStatus.new(team) %>
<tr> <tr>
<td><strong><%= team.name %></strong></td> <td><strong><%= team.name %></strong></td>
<td><%= team.sport %></td> <td><%= team.sport %></td>
<td>
<% if yt.selectable? %>
<% if yt.uses_platform_channel? %>
<span style="color:#81c784">Match Live TV</span>
<% if team.youtube_credential.blank? && team.entitlements.premium_full? %>
<span class="muted"> (default Full)</span>
<% end %>
<% else %>
<span style="color:#81c784">✓ <%= yt.channel_title.presence || "Canale società" %></span>
<% end %>
<% elsif team.entitlements.youtube_enabled? %>
<span class="muted">In configurazione</span>
<% else %>
<% end %>
</td>
<td><%= link_to "Partite e dettagli", admin_team_path(team) %></td> <td><%= link_to "Partite e dettagli", admin_team_path(team) %></td>
</tr> </tr>
<% end %> <% end %>

View File

@@ -13,7 +13,7 @@
<% if yt.selectable? %> <% if yt.selectable? %>
<strong><%= yt.channel_title || "—" %></strong> <strong><%= yt.channel_title || "—" %></strong>
<% if yt.uses_platform_channel? %> <% if yt.uses_platform_channel? %>
(canale piattaforma<%= @team.entitlements.premium_full? && @team.youtube_credential.blank? ? ", default senza OAuth società" : "" %>) (canale piattaforma<%= @team.entitlements.premium_full? && @team.club.youtube_credential.blank? ? ", default senza OAuth società" : "" %>)
<% else %> <% else %>
(canale società) (canale società)
<% end %> <% end %>
@@ -21,10 +21,10 @@
non pronto non pronto
<% end %> <% end %>
</p> </p>
<% if @team.entitlements.premium_full? && @team.youtube_credential.blank? %> <% if @team.entitlements.premium_full? && @team.club.youtube_credential.blank? %>
<p class="muted"> <p class="muted">
Senza canale collegato, lapp usa il canale Match Live TV. Senza canale collegato, lapp usa il canale Match Live TV.
<%= link_to "Collega canale (sito pubblico)", public_team_details_path(@team) %>. <%= link_to "Collega canale (pagina società)", public_club_path(@team.club) %>.
</p> </p>
<% end %> <% end %>

View File

@@ -7,7 +7,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="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=34"> <link rel="stylesheet" href="/marketing.css?v=34">
<link rel="stylesheet" href="/live.css?v=3"> <link rel="stylesheet" href="/live.css?v=4">
<%= yield :head %> <%= yield :head %>
</head> </head>
<body> <body>

View File

@@ -0,0 +1,91 @@
<% content_for :title, "Replay — #{@club.name}" %>
<% content_for :robots, "noindex, nofollow" %>
<div class="wrap club-dashboard" style="padding-top:20px">
<%= link_to "← #{@club.name}", public_club_path(@club), class: "back-link" %>
<h1>Archivio Replay</h1>
<p class="muted">Gestisci registrazioni, visibilità, download e pubblicazione YouTube.</p>
<div class="kpi-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin:20px 0">
<div class="card" style="padding:16px">
<div class="kpi-value"><%= @stats[:available_count] %></div>
<div class="kpi-sub">Replay disponibili</div>
</div>
<div class="card" style="padding:16px">
<div class="kpi-value"><%= @stats[:expiring_soon_count] %></div>
<div class="kpi-sub">In scadenza (7 gg)</div>
</div>
<div class="card" style="padding:16px">
<div class="kpi-value"><%= number_to_human_size(@stats[:total_bytes]) %></div>
<div class="kpi-sub">Spazio archivio</div>
</div>
<div class="card" style="padding:16px">
<div class="kpi-value"><%= @stats[:total_views] %></div>
<div class="kpi-sub">Visualizzazioni totali</div>
</div>
</div>
<% if @recordings.any? %>
<div class="card">
<table class="data">
<thead>
<tr>
<th></th>
<th>Titolo</th>
<th>Data</th>
<th>Durata</th>
<th>Views</th>
<th>Scadenza</th>
<th>Stato</th>
<th>Visibilità</th>
<th></th>
</tr>
</thead>
<tbody>
<% @recordings.each do |rec| %>
<tr>
<td style="width:72px">
<% if rec.thumbnail_url %>
<img src="<%= rec.thumbnail_url %>" alt="" width="64" height="36" style="object-fit:cover;border-radius:4px">
<% end %>
</td>
<td><%= rec.title_or_default %></td>
<td><%= l_local(rec.recorded_at_or_fallback) %></td>
<td><%= rec.duration_label %></td>
<td><%= rec.view_count %></td>
<td><%= rec.expires_at ? l_local(rec.expires_at) : "—" %></td>
<td><%= rec.status_label %></td>
<td>
<%= form_with url: public_club_recording_path(@club, rec), method: :patch, local: true do %>
<%= select_tag "recording[privacy_status]",
options_for_select([["Pubblico", "public"], ["Privato", "unlisted"]], rec.privacy_status),
onchange: "this.form.submit()" %>
<% end %>
</td>
<td style="white-space:nowrap;font-size:0.8rem">
<% if rec.replay_url %>
<%= link_to "Guarda", rec.replay_url, target: "_blank", rel: "noopener" %>
<% end %>
<% if rec.team.entitlements.phone_download_enabled? && rec.ready? %>
· <%= link_to "MP4", public_replay_download_path(rec.stream_session_id) %>
<% end %>
<% ent = rec.team.entitlements %>
<% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.youtube_video_id.blank? %>
· <%= button_to "YouTube", public_club_recording_publish_youtube_path(@club, rec), method: :post, class: "btn-link", style: "display:inline;padding:0;border:0;background:none;color:inherit;cursor:pointer;text-decoration:underline" %>
<% elsif rec.youtube_watch_url %>
· <%= link_to "YT", rec.youtube_watch_url, target: "_blank", rel: "noopener" %>
<% end %>
· <%= button_to "Elimina", public_club_recording_path(@club, rec), method: :delete,
class: "btn-link", style: "display:inline;padding:0;border:0;background:none;color:#e53935;cursor:pointer",
form: { data: { turbo_confirm: "Eliminare definitivamente questo replay?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="muted">Nessuna registrazione ancora. I replay compaiono al termine delle dirette Premium.</p>
<% end %>
</div>

View File

@@ -1,31 +1,32 @@
<% yt = Youtube::TeamStatus.new(team) %> <% return unless entitlements&.youtube_enabled? %>
<% return unless entitlements.youtube_enabled? %> <% yt = Youtube::TeamStatus.new(entitlements_team) if entitlements_team %>
<% cred = club.youtube_credential %>
<section class="card team-youtube" id="youtube"> <section class="card club-youtube" id="youtube">
<h2>YouTube Live</h2> <h2>YouTube Live</h2>
<% if params[:youtube] == "connected" %> <% if params[:youtube] == "connected" %>
<p class="notice" style="color:#2e7d32;margin-bottom:12px">Canale YouTube collegato con successo.</p> <p class="notice" style="color:#2e7d32;margin-bottom:12px">Canale YouTube della società collegato con successo.</p>
<% end %> <% end %>
<% if entitlements.plan.youtube_mode == "matchlivetv_light" %> <% if entitlements.plan.youtube_mode == "matchlivetv_light" %>
<p> <p>
Con il piano <strong>Premium Light</strong> la diretta va sul canale ufficiale Con il piano <strong>Premium Light</strong> le dirette YouTube di tutte le squadre vanno sul canale ufficiale
<strong>Match Live TV</strong>. Non serve collegare un canale della società. <strong>Match Live TV</strong>. Non serve collegare un canale della società.
</p> </p>
<% if yt.selectable? %> <% if yt&.selectable? %>
<p class="muted">Canale pronto · seleziona «YouTube Live» nellapp mobile.</p> <p class="muted">Canale pronto · seleziona «YouTube Live» nellapp mobile.</p>
<% else %> <% else %>
<p class="muted">Canale Match Live TV in configurazione lato server. Contatta il supporto se YouTube non compare in app.</p> <p class="muted">Canale Match Live TV in configurazione lato server. Contatta il supporto se YouTube non compare in app.</p>
<% end %> <% end %>
<% elsif entitlements.premium_full? %> <% elsif entitlements.premium_full? %>
<% cred = team.youtube_credential %>
<% if cred %> <% if cred %>
<p> <p>
Canale collegato: Canale collegato per <strong>tutta la società</strong>:
<strong><%= cred.channel_title.presence || cred.channel_id || "YouTube" %></strong> <strong><%= cred.channel_title.presence || cred.channel_id || "YouTube" %></strong>
</p> </p>
<%= button_to "Scollega canale", public_team_youtube_disconnect_path(team), <p class="muted">Tutte le squadre possono trasmettere su questo canale quando scelgono il canale società in app.</p>
<%= button_to "Scollega canale", public_club_youtube_disconnect_path(club),
method: :delete, method: :delete,
class: "btn btn-secondary", class: "btn btn-secondary",
form: { data: { turbo_confirm: "Scollegare il canale YouTube? Le dirette useranno il canale Match Live TV finché non ricolleghi il tuo." } } %> form: { data: { turbo_confirm: "Scollegare il canale YouTube? Le dirette useranno il canale Match Live TV finché non ricolleghi il tuo." } } %>
@@ -33,13 +34,13 @@
<p> <p>
Senza canale collegato, le dirette YouTube dallapp vanno sul canale ufficiale Senza canale collegato, le dirette YouTube dallapp vanno sul canale ufficiale
<strong>Match Live TV</strong>. <strong>Match Live TV</strong>.
Puoi collegare il canale della società quando vuoi per trasmettere sul tuo profilo. Collega qui il canale YouTube della società (vale per tutte le squadre).
</p> </p>
<% if yt.selectable? %> <% if yt&.selectable? %>
<p class="muted">YouTube pronto in app (canale Match Live TV).</p> <p class="muted">YouTube pronto in app (canale Match Live TV finché non colleghi il tuo).</p>
<% end %> <% end %>
<% if ENV["YOUTUBE_CLIENT_ID"].present? %> <% if ENV["YOUTUBE_CLIENT_ID"].present? %>
<%= link_to "Collega il tuo canale YouTube", public_team_youtube_connect_path(team), class: "btn btn-primary" %> <%= link_to "Collega il canale YouTube della società", public_club_youtube_connect_path(club), class: "btn btn-primary" %>
<% else %> <% else %>
<p class="muted">OAuth YouTube non ancora configurato sul server.</p> <p class="muted">OAuth YouTube non ancora configurato sul server.</p>
<% end %> <% end %>

View File

@@ -22,8 +22,38 @@
<%= link_to "Abbonamento", public_club_billing_path(@club), class: "btn btn-primary" %> <%= link_to "Abbonamento", public_club_billing_path(@club), class: "btn btn-primary" %>
<%= link_to "Nuova squadra", public_new_club_team_path(@club), class: "btn btn-secondary" %> <%= link_to "Nuova squadra", public_new_club_team_path(@club), class: "btn btn-secondary" %>
<%= link_to "Dirette live", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %> <%= link_to "Dirette live", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
<% if @entitlements&.can_access_recordings? %>
<%= link_to "Archivio Replay", public_club_recordings_path(@club), class: "btn btn-secondary" %>
<% end %>
</div> </div>
<%= render "public/clubs/youtube",
club: @club,
entitlements: @entitlements,
entitlements_team: @entitlements_team %>
<% if @replay_stats %>
<h2>Replay</h2>
<div class="card" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:16px;padding:16px;margin-bottom:24px">
<div>
<div style="font-size:1.6rem;font-weight:700"><%= @replay_stats[:available_count] %></div>
<div class="muted">disponibili</div>
</div>
<div>
<div style="font-size:1.6rem;font-weight:700"><%= @replay_stats[:expiring_soon_count] %></div>
<div class="muted">in scadenza (7 gg)</div>
</div>
<div>
<div style="font-size:1.6rem;font-weight:700"><%= number_to_human_size(@replay_stats[:total_bytes]) %></div>
<div class="muted">spazio occupato</div>
</div>
<div>
<div style="font-size:1.6rem;font-weight:700"><%= @replay_stats[:total_views] %></div>
<div class="muted">visualizzazioni</div>
</div>
</div>
<% end %>
<h2>Squadre</h2> <h2>Squadre</h2>
<div class="card"> <div class="card">
<% if @teams.any? %> <% if @teams.any? %>

View File

@@ -12,11 +12,17 @@
<p class="results-hint"> <p class="results-hint">
Solo le squadre di questa società: dirette in corso e partite programmate da app o sito. Solo le squadre di questa società: dirette in corso e partite programmate da app o sito.
</p> </p>
<div class="live-actions" style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">
<%= link_to "Replay di #{@club.name}", public_replay_index_path(club_id: @club.id), class: "btn btn-secondary" %>
</div>
<% else %> <% else %>
<h1>Dirette e partite in programma</h1> <h1>Dirette e partite in programma</h1>
<p class="results-hint"> <p class="results-hint">
Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate. Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate.
</p> </p>
<div class="live-actions" style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px">
<%= link_to "Live passate — archivio replay", public_replay_index_path, class: "btn btn-secondary" %>
</div>
<% end %> <% end %>
<% if @club && @can_schedule_match %> <% if @club && @can_schedule_match %>

View File

@@ -0,0 +1,90 @@
<% content_for :title, "Replay — Match Live TV" %>
<% content_for :meta_description, "Archivio replay delle dirette sportive su Match Live TV. Cerca per società, squadra o avversario." %>
<div class="wrap replay-index">
<%= link_to "← Dirette live", public_live_index_path, class: "back-link" %>
<h1>Live passate</h1>
<p class="results-hint">Replay pubblici delle società sportive — riguarda le partite già trasmesse.</p>
<%= form_with url: public_replay_index_path, method: :get, local: true, class: "search-form" do %>
<% if params[:club_id].present? %>
<%= hidden_field_tag :club_id, params[:club_id] %>
<% end %>
<input
type="search"
name="q"
value="<%= @query %>"
placeholder="Es. Tigers Volley, avversario, società, luogo…"
aria-label="Cerca replay"
autocomplete="off"
/>
<button type="submit" class="btn btn-primary">Cerca</button>
<% if @query.present? || params[:club_id].present? %>
<%= link_to "Azzera", public_replay_index_path, class: "btn btn-secondary" %>
<% end %>
<% end %>
<% if @clubs.many? %>
<%= form_with url: public_replay_index_path, method: :get, local: true, class: "replay-filter-form" do %>
<% if @query.present? %>
<%= hidden_field_tag :q, @query %>
<% end %>
<label for="club_id" class="muted">Società</label>
<select name="club_id" id="club_id" class="input" onchange="this.form.submit()">
<option value="">Tutte le società</option>
<% @clubs.each do |club| %>
<option value="<%= club.id %>"<%= " selected" if params[:club_id].to_s == club.id.to_s %>><%= club.name %></option>
<% end %>
</select>
<% end %>
<% end %>
<% if @query.present? %>
<p class="results-hint">
Risultati per «<%= h @query %>»<% if @filter_club %> · <%= @filter_club.name %><% end %>:
<%= @recordings.size %> replay
</p>
<% end %>
<% if @recordings.any? %>
<div class="replay-grid">
<% @recordings.each do |rec| %>
<% match = rec.stream_session.match %>
<%= link_to public_replay_path(rec.stream_session_id), class: "replay-card" do %>
<div class="replay-card__media">
<% if rec.thumbnail_url %>
<img src="<%= rec.thumbnail_url %>" alt="" class="replay-card__thumb" loading="lazy" width="320" height="180">
<% else %>
<div class="replay-card__thumb replay-card__thumb--placeholder" aria-hidden="true">
<span class="replay-card__placeholder-icon">▶</span>
</div>
<% end %>
<span class="replay-card__duration"><%= rec.duration_label %></span>
<span class="replay-card__play" aria-hidden="true">▶</span>
</div>
<div class="replay-card__body">
<strong class="replay-card__title"><%= rec.title_or_default %></strong>
<p class="replay-card__meta">
<%= match.team.club.name %> · <%= l_local(rec.recorded_at_or_fallback) %>
</p>
<p class="replay-card__meta replay-card__meta--sub">
<%= rec.views_label %>
</p>
</div>
<% end %>
<% end %>
</div>
<% else %>
<div class="empty-state">
<% if @query.present? || params[:club_id].present? %>
<p><strong>Nessun replay trovato</strong> con i filtri selezionati.</p>
<p><%= link_to "Mostra tutti i replay pubblici", public_replay_index_path, class: "btn btn-secondary" %></p>
<% else %>
<p><strong>Nessun replay pubblico al momento.</strong></p>
<p>Quando una società rende pubblica una registrazione, comparirà qui.</p>
<%= link_to "Vai alle dirette live", public_live_index_path, class: "btn btn-secondary", style: "margin-top:12px;display:inline-block" %>
<% end %>
</div>
<% end %>
</div>

View File

@@ -1,39 +1,58 @@
<% content_for :title, "Replay — #{@match.team.name} vs #{@match.opponent_name}" %> <% content_for :title, "Replay — #{@recording.title_or_default}" %>
<% content_for :meta_description, "Replay partita #{@match.team.name} vs #{@match.opponent_name} su Match Live TV." %> <% content_for :meta_description, "Replay #{@recording.title_or_default} su Match Live TV." %>
<% content_for :robots, "noindex, nofollow" %> <% content_for :robots, @recording.publicly_listed? ? "index, follow" : "noindex, nofollow" %>
<div class="wrap"> <div class="wrap replay-show">
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %> <%= link_to "← Live passate", public_replay_index_path, class: "back-link" %>
<h1>Replay</h1> <header class="replay-show__header">
<p style="color:#aaa"><%= @match.team.name %> vs <%= @match.opponent_name %></p> <h1><%= @recording.title_or_default %></h1>
<p class="replay-show__meta">
<%= @match.team.club.name %> · <%= l_local(@recording.recorded_at_or_fallback) %>
· <%= @recording.views_label %>
</p>
</header>
<% if @recording %> <% if @recording.status == "processing" %>
<div class="card" style="margin:16px 0"> <div class="card replay-show__status">
<% if @session.hls_playback_url.present? %> <p>Registrazione in elaborazione. Riceverai unemail quando sarà pronta.</p>
<video id="replay-player" class="replay-video" controls playsinline></video> </div>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script> <% elsif @recording.ready? %>
<script> <div class="replay-show__player card">
(function() { <video id="replay-player" class="replay-video" controls playsinline preload="metadata"
var src = "<%= j @session.hls_playback_url %>"; <% if @recording.thumbnail_url %> poster="<%= @recording.thumbnail_url %>"<% end %>>
var video = document.getElementById("replay-player"); <source src="<%= public_replay_stream_path(@session) %>" type="video/mp4">
if (video.canPlayType("application/vnd.apple.mpegurl")) { </video>
video.src = src; </div>
} else if (typeof Hls !== "undefined" && Hls.isSupported()) {
var hls = new Hls(); <div class="replay-show__details card">
hls.loadSource(src); <p class="replay-show__details-line">
hls.attachMedia(video); <span>Durata <%= @recording.duration_label %></span>
} else {
video.outerHTML = "<p>Il replay non è disponibile su questo browser.</p>";
}
})();
</script>
<% else %>
<p>Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.</p>
<% end %>
<% if @recording.expires_at %> <% if @recording.expires_at %>
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l_local(@recording.expires_at) %></p> <span>· Disponibile fino al <%= l_local(@recording.expires_at) %></span>
<% end %> <% end %>
<span>· <%= @recording.publicly_listed? ? "Pubblico" : "Privato (link)" %></span>
<% if @recording.byte_size.to_i.positive? %>
<span>· <%= @recording.byte_size_label %></span>
<% end %>
</p>
<% ent = @recording.team.entitlements %>
<div class="replay-show__actions">
<% if ent.phone_download_enabled? && (logged_in? || @recording.unlisted?) %>
<%= link_to "Scarica MP4", public_replay_download_path(@session), class: "btn btn-primary" %>
<% end %>
<% if @recording.youtube_watch_url %>
<%= link_to "Apri su YouTube", @recording.youtube_watch_url, class: "btn btn-secondary", target: "_blank", rel: "noopener" %>
<% end %>
</div>
</div>
<% elsif @recording.status == "failed" %>
<div class="card replay-show__status">
<p>Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.</p>
</div>
<% else %>
<div class="card replay-show__status">
<p>Replay non più disponibile.</p>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -81,20 +81,37 @@
</div> </div>
<% end %> <% end %>
<% if entitlements.can_access_recordings? && recordings.any? %> <% if entitlements.can_access_recordings? %>
<h2>Archivio gare</h2> <h2>Archivio replay</h2>
<div class="card"> <div class="card">
<% if recordings.any? %>
<table class="data"> <table class="data">
<thead><tr><th>Partita</th><th>Replay</th></tr></thead> <thead><tr><th>Partita</th><th>Data</th><th>Durata</th><th>Scadenza</th><th>Stato</th><th></th></tr></thead>
<tbody> <tbody>
<% recordings.each do |rec| %> <% recordings.each do |rec| %>
<tr> <tr>
<td><%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %></td> <td><%= rec.title_or_default %></td>
<td><%= link_to "Guarda", rec.replay_url %></td> <td><%= l_local(rec.recorded_at_or_fallback) %></td>
<td><%= rec.duration_label %></td>
<td><%= rec.expires_at ? l_local(rec.expires_at) : "—" %></td>
<td><%= rec.status_label %></td>
<td>
<% if rec.replay_url %>
<%= link_to "Guarda", rec.replay_url, target: "_blank", rel: "noopener" %>
<% else %>
<% end %>
</td>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>
</table> </table>
<p style="margin-top:12px">
<%= link_to "Gestisci tutti i replay", public_club_recordings_path(team.club) %>
</p>
<% else %>
<p class="muted">Nessun replay ancora. Al termine delle dirette Premium le registrazioni compaiono qui.</p>
<% end %>
</div> </div>
<% end %> <% end %>
</section> </section>

View File

@@ -92,7 +92,12 @@
<% end %> <% end %>
</div> </div>
<%= render "public/teams/youtube", team: @team, entitlements: @entitlements %> <% if @can_manage && @entitlements.premium_full? && @entitlements.youtube_enabled? %>
<p class="muted" style="margin:16px 0">
Il canale YouTube della società si configura dalla pagina
<%= link_to @club.name, public_club_path(@club) %> (sezione YouTube Live).
</p>
<% end %>
<%= render "public/teams/streaming_staff", team: @team, club: @club, can_manage: @can_manage, <%= render "public/teams/streaming_staff", team: @team, club: @club, can_manage: @can_manage,
entitlements: @entitlements, owner_membership: @owner_membership, entitlements: @entitlements, owner_membership: @owner_membership,

View File

@@ -0,0 +1,13 @@
<p>Ciao <%= @recipient.name %>,</p>
<p>Il replay <strong><%= @recording.title_or_default %></strong> scadrà tra circa <strong><%= @days_left %> giorni</strong> (<%= l @expires_at, format: :long %>).</p>
<p>Dopo la scadenza verrà eliminato automaticamente dallo storage secondo il piano della società.</p>
<p>
<a href="<%= @replay_url %>">Apri il replay</a>
·
<a href="<%= MatchLiveTv.app_public_url.chomp('/') %>/clubs/<%= @club.id %>/replays">Archivio Replay</a>
</p>
<p style="color:#666;font-size:12px">Match Live TV — <%= MatchLiveTv.privacy_controller_email %></p>

View File

@@ -0,0 +1,8 @@
Ciao <%= @recipient.name %>,
Il replay "<%= @recording.title_or_default %>" scade tra circa <%= @days_left %> giorni (<%= l @expires_at, format: :long %>).
Apri: <%= @replay_url %>
Archivio: <%= MatchLiveTv.app_public_url.chomp('/') %>/clubs/<%= @club.id %>/replays
Match Live TV

View File

@@ -0,0 +1,15 @@
<p>Ciao <%= @recipient.name %>,</p>
<p>Il replay della partita <strong><%= @recording.title_or_default %></strong> (<%= @club.name %>) è pronto e disponibile sulla piattaforma.</p>
<p>
<a href="<%= @replay_url %>">Guarda il replay</a>
</p>
<% if @expires_at %>
<p style="color:#666">Disponibile fino al <%= l @expires_at, format: :long %>.</p>
<% end %>
<p>Puoi gestire visibilità ed eliminazione dall<a href="<%= MatchLiveTv.app_public_url.chomp('/') %>/clubs/<%= @club.id %>/replays">archivio Replay</a> della società.</p>
<p style="color:#666;font-size:12px">Match Live TV — <%= MatchLiveTv.privacy_controller_email %></p>

View File

@@ -0,0 +1,12 @@
Ciao <%= @recipient.name %>,
Il replay "<%= @recording.title_or_default %>" (<%= @club.name %>) è pronto.
Guarda: <%= @replay_url %>
<% if @expires_at %>
Disponibile fino al <%= l @expires_at, format: :long %>.
<% end %>
Archivio: <%= MatchLiveTv.app_public_url.chomp('/') %>/clubs/<%= @club.id %>/replays
Match Live TV

View File

@@ -10,8 +10,9 @@
<%= link_to "Privacy", public_privacy_path %> · <%= link_to "Privacy", public_privacy_path %> ·
<%= link_to "Termini", public_termini_path %> <%= link_to "Termini", public_termini_path %>
</div> </div>
<div class="site-footer__stripe"> <div class="site-footer__legal">
<%= render "shared/stripe_secure_payment", compact: true %> <p>© 2026 Emiliano Frascaro P. IVA 14230270960</p>
<p>I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.</p>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -96,5 +96,49 @@ module MatchLiveTv
def password_reset_expiry_hours def password_reset_expiry_hours
ENV.fetch("PASSWORD_RESET_EXPIRY_HOURS", "2").to_i ENV.fetch("PASSWORD_RESET_EXPIRY_HOURS", "2").to_i
end end
def replay_storage_configured?
replay_storage_endpoint.present? || replay_storage_local?
end
def replay_storage_endpoint
ENV["REPLAY_STORAGE_ENDPOINT"].presence
end
def replay_storage_bucket
ENV.fetch("REPLAY_STORAGE_BUCKET", "matchlivetv-replays")
end
def replay_storage_region
ENV.fetch("REPLAY_STORAGE_REGION", "garage")
end
def replay_storage_access_key_id
ENV["REPLAY_STORAGE_ACCESS_KEY_ID"].presence
end
def replay_storage_secret_access_key
ENV["REPLAY_STORAGE_SECRET_ACCESS_KEY"].presence
end
def replay_storage_force_path_style?
ENV.fetch("REPLAY_STORAGE_FORCE_PATH_STYLE", "true") == "true"
end
def replay_storage_local?
replay_storage_endpoint.blank?
end
def recordings_local_path
ENV.fetch("RECORDINGS_PATH", "/recordings")
end
def replay_presigned_url_ttl_seconds
ENV.fetch("REPLAY_PRESIGNED_URL_TTL_SECONDS", "7200").to_i
end
def replay_download_url_ttl_seconds
ENV.fetch("REPLAY_DOWNLOAD_URL_TTL_SECONDS", "900").to_i
end
end end
end end

View File

@@ -24,6 +24,13 @@ Rails.application.routes.draw do
get "youtube/authorize", to: "youtube#authorize" get "youtube/authorize", to: "youtube#authorize"
end end
resources :recordings, only: %i[update destroy], controller: "recordings" do
member do
get :download
post :publish_youtube
end
end
resources :matches, only: %i[show update destroy] do resources :matches, only: %i[show update destroy] do
resources :sessions, only: %i[create], controller: "stream_sessions" resources :sessions, only: %i[create], controller: "stream_sessions"
end end
@@ -87,7 +94,11 @@ Rails.application.routes.draw do
get "live", to: "public/live#index", as: :public_live_index get "live", to: "public/live#index", as: :public_live_index
get "live/:id", to: "public/live#show", as: :public_live get "live/:id", to: "public/live#show", as: :public_live
get "live/:id/status.json", to: "public/live#status", as: :public_live_status get "live/:id/status.json", to: "public/live#status", as: :public_live_status
get "replay", to: "public/replay#index", as: :public_replay_index
get "replay/:id", to: "public/replay#show", as: :public_replay get "replay/:id", to: "public/replay#show", as: :public_replay
get "replay/:id/stream", to: "public/replay#stream", as: :public_replay_stream
get "replay/:id/thumbnail", to: "public/replay#thumbnail", as: :public_replay_thumbnail
get "replay/:id/download", to: "public/replay#download", as: :public_replay_download
get "regia/:token", to: "public/regia#show", as: :public_regia get "regia/:token", to: "public/regia#show", as: :public_regia
get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status
@@ -121,12 +132,18 @@ Rails.application.routes.draw do
get "clubs/:id/edit", to: "clubs#edit", as: :edit_club get "clubs/:id/edit", to: "clubs#edit", as: :edit_club
patch "clubs/:id", to: "clubs#update" patch "clubs/:id", to: "clubs#update"
get "clubs/:id/billing", to: "clubs#billing", as: :club_billing get "clubs/:id/billing", to: "clubs#billing", as: :club_billing
get "clubs/:club_id/replays", to: "club_recordings#index", as: :club_recordings
patch "clubs/:club_id/replays/:id", to: "club_recordings#update", as: :club_recording
delete "clubs/:club_id/replays/:id", to: "club_recordings#destroy"
post "clubs/:club_id/replays/:id/publish_youtube", to: "club_recordings#publish_youtube", as: :club_recording_publish_youtube
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile
patch "clubs/:id/billing/profile", to: "club_billing#update_profile" patch "clubs/:id/billing/profile", to: "club_billing#update_profile"
post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription
get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
get "clubs/:id/youtube/connect", to: "clubs#youtube_connect", as: :club_youtube_connect
delete "clubs/:id/youtube/disconnect", to: "clubs#youtube_disconnect", as: :club_youtube_disconnect
get "clubs/:club_id/matches/new", to: "club_matches#new", as: :new_club_match get "clubs/:club_id/matches/new", to: "club_matches#new", as: :new_club_match
post "clubs/:club_id/matches", to: "club_matches#create", as: :club_matches post "clubs/:club_id/matches", to: "club_matches#create", as: :club_matches
get "teams/new", to: redirect("/clubs/new") get "teams/new", to: redirect("/clubs/new")

View File

@@ -0,0 +1,21 @@
class EnhanceRecordingsForReplayModule < ActiveRecord::Migration[7.2]
def change
change_table :recordings, bulk: true do |t|
t.string :title
t.string :privacy_status, null: false, default: "unlisted"
t.bigint :byte_size
t.integer :duration_secs
t.string :storage_key
t.string :storage_bucket
t.string :storage_backend, null: false, default: "local"
t.datetime :recorded_at
t.datetime :deleted_at
t.text :error_message
end
add_index :recordings, :privacy_status
add_index :recordings, :deleted_at
add_index :recordings, %i[team_id status]
add_index :recordings, :storage_key, unique: true, where: "storage_key IS NOT NULL"
end
end

View File

@@ -0,0 +1,16 @@
class ReplayEnhancements < ActiveRecord::Migration[7.2]
def change
change_table :recordings, bulk: true do |t|
t.jsonb :metadata, null: false, default: {}
t.string :thumbnail_storage_key
t.integer :view_count, null: false, default: 0
t.datetime :ready_notified_at
t.datetime :expiry_warning_sent_at
t.string :youtube_video_id
t.datetime :youtube_published_at
end
add_index :recordings, :view_count
add_index :recordings, :youtube_video_id, where: "youtube_video_id IS NOT NULL"
end
end

View File

@@ -0,0 +1,57 @@
# frozen_string_literal: true
class MoveYoutubeCredentialsToClubs < ActiveRecord::Migration[7.2]
def up
unless column_exists?(:youtube_credentials, :club_id)
add_reference :youtube_credentials, :club, type: :uuid, foreign_key: true
end
if column_exists?(:youtube_credentials, :team_id)
say_with_time "Associa credenziali YouTube alle società" do
execute <<~SQL.squish
UPDATE youtube_credentials yc
SET club_id = t.club_id
FROM teams t
WHERE t.id = yc.team_id AND yc.club_id IS NULL
SQL
end
execute <<~SQL.squish
DELETE FROM youtube_credentials yc
USING youtube_credentials newer
WHERE yc.club_id = newer.club_id
AND yc.id <> newer.id
AND yc.updated_at < newer.updated_at
SQL
change_column_null :youtube_credentials, :club_id, false
remove_index :youtube_credentials, :team_id, if_exists: true
remove_reference :youtube_credentials, :team, foreign_key: true
end
unless index_exists?(:youtube_credentials, :club_id)
add_index :youtube_credentials, :club_id, unique: true
end
end
def down
return unless column_exists?(:youtube_credentials, :club_id)
add_reference :youtube_credentials, :team, type: :uuid, foreign_key: true unless column_exists?(:youtube_credentials, :team_id)
execute <<~SQL.squish
UPDATE youtube_credentials yc
SET team_id = (
SELECT t.id FROM teams t
WHERE t.club_id = yc.club_id
ORDER BY t.created_at
LIMIT 1
)
SQL
change_column_null :youtube_credentials, :team_id, false
remove_index :youtube_credentials, :club_id, if_exists: true
remove_reference :youtube_credentials, :club, foreign_key: true
add_index :youtube_credentials, :team_id, if_not_exists: true
end
end

31
backend/db/schema.rb generated
View File

@@ -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_06_03_120000) do ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) 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"
@@ -176,9 +176,32 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
t.datetime "expires_at" t.datetime "expires_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "title"
t.string "privacy_status", default: "unlisted", null: false
t.bigint "byte_size"
t.integer "duration_secs"
t.string "storage_key"
t.string "storage_bucket"
t.string "storage_backend", default: "local", null: false
t.datetime "recorded_at"
t.datetime "deleted_at"
t.text "error_message"
t.jsonb "metadata", default: {}, null: false
t.string "thumbnail_storage_key"
t.integer "view_count", default: 0, null: false
t.datetime "ready_notified_at"
t.datetime "expiry_warning_sent_at"
t.string "youtube_video_id"
t.datetime "youtube_published_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 ["storage_key"], name: "index_recordings_on_storage_key", unique: true, where: "(storage_key IS NOT NULL)"
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"], name: "index_recordings_on_team_id" t.index ["team_id"], name: "index_recordings_on_team_id"
t.index ["view_count"], name: "index_recordings_on_view_count"
t.index ["youtube_video_id"], name: "index_recordings_on_youtube_video_id", where: "(youtube_video_id IS NOT NULL)"
end end
create_table "score_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "score_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -334,7 +357,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
end end
create_table "youtube_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "youtube_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "team_id", null: false
t.text "access_token_encrypted" t.text "access_token_encrypted"
t.text "refresh_token_encrypted" t.text "refresh_token_encrypted"
t.datetime "expires_at" t.datetime "expires_at"
@@ -342,7 +364,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
t.string "channel_title" t.string "channel_title"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["team_id"], name: "index_youtube_credentials_on_team_id" t.uuid "club_id", null: false
t.index ["club_id"], name: "index_youtube_credentials_on_club_id"
end end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
@@ -369,5 +392,5 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
add_foreign_key "teams", "clubs" add_foreign_key "teams", "clubs"
add_foreign_key "user_teams", "teams" add_foreign_key "user_teams", "teams"
add_foreign_key "user_teams", "users" add_foreign_key "user_teams", "users"
add_foreign_key "youtube_credentials", "teams" add_foreign_key "youtube_credentials", "clubs"
end end

View File

@@ -0,0 +1,13 @@
namespace :recordings do
desc "Elimina replay scaduti (DB + storage)"
task purge_expired: :environment do
Recordings::PurgeExpiredJob.new.perform
puts "Purge replay scaduti completata"
end
desc "Invia email di avviso scadenza replay (7 giorni)"
task expiry_warnings: :environment do
Recordings::ExpiryWarningJob.new.perform
puts "Avvisi scadenza replay inviati"
end
end

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
namespace :replay do
desc "Test end-to-end: segmenti finti → merge → upload Garage → verifica S3"
task e2e_garage: :environment do
abort "Garage non configurato: imposta REPLAY_STORAGE_ENDPOINT in infra/.env" if MatchLiveTv.replay_storage_local?
team = Team.joins(:club).find { |t| t.entitlements.can_access_recordings? }
abort "Nessuna squadra Premium nel DB" unless team
user = User.where(role: %w[coach admin]).first || User.first
abort "Nessun utente nel DB" unless user
match = team.matches.create!(
opponent_name: "E2E Garage Test",
scheduled_at: Time.current
)
session = StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "ended",
privacy_status: "public",
started_at: 5.minutes.ago,
ended_at: Time.current
)
recording = Recordings::FinalizeSession.new(session).call
abort "FinalizeSession non ha creato recording" unless recording
segment_dir = File.join(MatchLiveTv.recordings_local_path, session.mediamtx_path_name)
FileUtils.mkdir_p(segment_dir)
segment = File.join(segment_dir, "segment-001.mp4")
ok = system(
"ffmpeg", "-y",
"-f", "lavfi", "-i", "testsrc=duration=8:size=320x240:rate=10",
"-f", "lavfi", "-i", "sine=frequency=440:duration=8",
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
"-c:a", "aac", "-shortest",
segment,
out: File::NULL, err: File::NULL
)
abort "ffmpeg non ha creato il segmento di test" unless ok && File.exist?(segment)
puts "[e2e] Session #{session.id} — upload in corso..."
Recordings::UploadFromSession.new(session).call
recording.reload
unless recording.status == "ready" && recording.storage_backend == "s3"
abort "Recording non ready/s3: status=#{recording.status} backend=#{recording.storage_backend} err=#{recording.error_message}"
end
require "aws-sdk-s3"
client = Aws::S3::Client.new(
access_key_id: MatchLiveTv.replay_storage_access_key_id,
secret_access_key: MatchLiveTv.replay_storage_secret_access_key,
endpoint: MatchLiveTv.replay_storage_endpoint,
region: MatchLiveTv.replay_storage_region,
force_path_style: MatchLiveTv.replay_storage_force_path_style?
)
head = client.head_object(bucket: recording.storage_bucket, key: recording.storage_key)
url = Recordings::Storage.new.presigned_get_url(key: recording.storage_key, expires_in: 120)
puts "[e2e] OK"
puts " recording_id: #{recording.id}"
puts " storage_key: #{recording.storage_key}"
puts " byte_size: #{recording.byte_size} (S3: #{head.content_length})"
puts " thumbnail: #{recording.thumbnail_storage_key || '(nessuna)'}"
puts " presigned: #{url[0, 80]}..."
if recording.thumbnail_storage_key.present?
client.head_object(bucket: recording.storage_bucket, key: recording.thumbnail_storage_key)
puts " thumbnail su Garage: OK"
end
end
end

View File

@@ -356,3 +356,171 @@
border-radius: 12px; border-radius: 12px;
background: #000; background: #000;
} }
.live-main .replay-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin-top: 24px;
}
.live-main a.replay-card {
display: block;
text-decoration: none;
color: #eee;
background: #14141c;
border: 1px solid #2a2a36;
border-radius: 14px;
overflow: hidden;
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
.live-main a.replay-card:hover {
border-color: #e53935;
transform: translateY(-3px);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
color: #fff;
text-decoration: none;
}
.live-main .replay-card__media {
position: relative;
background: #0a0a0e;
}
.live-main .replay-card__thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: #111;
display: block;
vertical-align: middle;
}
.live-main .replay-card__thumb--placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 140px;
background: linear-gradient(145deg, #1a1a24 0%, #0d0d12 100%);
}
.live-main .replay-card__placeholder-icon {
font-size: 2rem;
color: #444;
}
.live-main .replay-card__play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(229, 57, 53, 0.92);
color: #fff;
font-size: 1.1rem;
line-height: 52px;
text-align: center;
padding-left: 4px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
pointer-events: none;
}
.live-main .replay-card__duration {
position: absolute;
right: 8px;
bottom: 8px;
padding: 3px 8px;
border-radius: 6px;
background: rgba(0, 0, 0, 0.75);
color: #fff;
font-size: 0.75rem;
font-weight: 600;
}
.live-main .replay-card__body {
padding: 12px 14px 14px;
}
.live-main .replay-card__title {
display: block;
font-size: 1rem;
margin: 0 0 6px;
color: #fff;
line-height: 1.3;
}
.live-main .replay-card__meta {
margin: 0;
font-size: 0.85rem;
color: #999;
}
.live-main .replay-card__meta--sub {
margin-top: 4px;
font-size: 0.8rem;
color: #777;
}
.live-main .replay-show__header {
margin-bottom: 20px;
}
.live-main .replay-show__header h1 {
margin: 0 0 8px;
font-size: 1.6rem;
}
.live-main .replay-show__meta {
margin: 0;
color: #999;
font-size: 0.95rem;
}
.live-main .replay-show__player {
padding: 0;
overflow: hidden;
margin-bottom: 16px;
}
.live-main .replay-show__player .replay-video {
display: block;
width: 100%;
border-radius: 12px;
}
.live-main .replay-show__details {
padding: 16px 18px;
}
.live-main .replay-show__details-line {
margin: 0 0 14px;
color: #aaa;
font-size: 0.9rem;
line-height: 1.5;
}
.live-main .replay-show__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.live-main .replay-filter-form {
margin: 12px 0 20px;
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.live-main .replay-filter-form .input {
min-width: 200px;
padding: 10px 12px;
border: 1px solid #333;
border-radius: 10px;
background: #14141c;
color: #fff;
}

View File

@@ -1014,7 +1014,9 @@ 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; }
.site-footer__stripe { 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:last-child { margin-bottom: 0; }
.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; } .compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; }
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; } .compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
.compare-table th { color: #aaa; font-weight: 600; } .compare-table th { color: #aaa; font-weight: 600; }

View File

@@ -0,0 +1,34 @@
require "rails_helper"
RSpec.describe Recording 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: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "ended",
privacy_status: "public"
)
end
it "is ready when status ready and not expired" do
rec = described_class.create!(
stream_session: session,
team: team,
status: "ready",
expires_at: 1.day.from_now,
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4"
)
expect(rec).to be_ready
expect(rec.replay_url).to include("/replay/#{session.id}")
end
it "exposes default title from match" do
rec = described_class.create!(stream_session: session, team: team, status: "processing")
expect(rec.title_or_default).to eq("Team vs Rival")
end
end

View File

@@ -0,0 +1,35 @@
require "rails_helper"
RSpec.describe Recordings::AccessPolicy 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: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended")
end
let!(:recording) do
Recording.create!(
stream_session: session,
team: team,
status: "ready",
privacy_status: "unlisted",
expires_at: 10.days.from_now,
storage_key: "key"
)
end
it "allows unlisted replay without login" do
expect(described_class.new(recording, viewer: nil).allowed?).to be true
end
it "allows public replay without login" do
recording.update!(privacy_status: "public")
expect(described_class.new(recording, viewer: nil).allowed?).to be true
end
it "denies expired replay" do
recording.update!(expires_at: 1.day.ago)
expect(described_class.new(recording, viewer: nil).allowed?).to be false
end
end

View File

@@ -0,0 +1,26 @@
require "rails_helper"
RSpec.describe Recordings::ClubStats 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: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended")
end
it "aggregates ready replays" do
Recording.create!(
stream_session: session,
team: team,
status: "ready",
byte_size: 100.megabytes,
expires_at: 3.days.from_now,
storage_key: "a"
)
stats = described_class.new(club).call
expect(stats[:available_count]).to eq(1)
expect(stats[:expiring_soon_count]).to eq(1)
expect(stats[:total_bytes]).to eq(100.megabytes)
end
end

View File

@@ -0,0 +1,35 @@
require "rails_helper"
RSpec.describe Recordings::FinalizeSession 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: "coach@test.com", name: "Coach", password: "password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) do
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "ended",
privacy_status: "public",
ended_at: Time.current
)
end
before do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_light")
end
it "creates processing recording with retention" do
rec = described_class.new(session).call
expect(rec.status).to eq("processing")
expect(rec.privacy_status).to eq("public")
expect(rec.expires_at).to be > 29.days.from_now
end
it "skips free plan" do
Billing::AssignPlan.call(club: club, plan_slug: "free")
expect(described_class.new(session).call).to be_nil
end
end

View File

@@ -0,0 +1,24 @@
require "rails_helper"
RSpec.describe Recordings::IncrementViews 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: "c@test.com", name: "C", password: "password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") }
let!(:recording) do
Recording.create!(
stream_session: session,
team: team,
status: "ready",
expires_at: 10.days.from_now,
storage_key: "key",
view_count: 2
)
end
it "increments view_count" do
described_class.new(recording).call
expect(recording.reload.view_count).to eq(3)
end
end

View File

@@ -0,0 +1,30 @@
require "rails_helper"
RSpec.describe Recordings::NotifyReady do
let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:owner) { User.create!(email: "owner@test.com", name: "Owner", password: "password123", role: "coach") }
let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") }
let!(:user) { User.create!(email: "c@test.com", name: "C", password: "password123", role: "coach") }
let!(:match) { team.matches.create!(opponent_name: "Rival", scheduled_at: 1.day.from_now) }
let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended") }
before do
ClubMembership.create!(club: club, user: owner, role: "owner")
end
it "sends email and marks notified" do
recording = Recording.create!(
stream_session: session,
team: team,
status: "ready",
expires_at: 10.days.from_now,
storage_key: "key"
)
expect {
described_class.new(recording).call
}.to change { ActionMailer::Base.deliveries.size }.by(1)
expect(recording.reload.ready_notified_at).to be_present
end
end

View File

@@ -16,7 +16,7 @@ Percorso per il **responsabile trasmissione** invitato via email: accede in app
| Premium Full, canale collegato | Canale **della società** | | Premium Full, canale collegato | Canale **della società** |
| Premium Full, canale non collegato | **Match Live TV** (default, come Light) | | Premium Full, canale non collegato | **Match Live TV** (default, come Light) |
Il collegamento del canale società è **opzionale** su Full: si fa dal sito (dettaglio squadra) o in futuro da app; finché non cè OAuth, lapp trasmette sul canale piattaforma. Il collegamento del canale società è **opzionale** su Full: si fa dal sito (pagina società → sezione YouTube Live) o in futuro da app; finché non cè OAuth, lapp trasmette sul canale piattaforma.
## 1. Invito ## 1. Invito
@@ -31,7 +31,7 @@ API: `GET /api/v1/invitations/:token`, `POST /api/v1/invitations/:token/accept`
Solo se la società vuole il **proprio** canale invece di Match Live TV: Solo se la società vuole il **proprio** canale invece di Match Live TV:
1. Sul sito: dettaglio squadra**Collega il tuo canale YouTube** 1. Sul sito: pagina società (`/clubs/:id`)**Collega il canale YouTube della società**
2. Oppure (se abilitato) OAuth da app al tocco YouTube quando non cè ancora canale piattaforma 2. Oppure (se abilitato) OAuth da app al tocco YouTube quando non cè ancora canale piattaforma
## 3. Scegli la partita ## 3. Scegli la partita

105
docs/REPLAY_MODULE.md Normal file
View File

@@ -0,0 +1,105 @@
# Modulo Replay — Match Live TV
## Panoramica
Al termine di ogni diretta Premium, MediaMTX registra i segmenti. Sidekiq unisce i segmenti, genera thumbnail, carica su **Garage (S3-compatible)** e attiva i servizi di notifica, statistiche e (opzionale) republicazione YouTube.
## Piani e retention
| Piano | Registrazione | Retention | Download MP4 | YouTube VOD |
|-------|---------------|-----------|--------------|-------------|
| Free | No | — | No | No |
| Premium Light | Sì | 30 giorni | Sì | No |
| Premium Full | Sì | 90 giorni | Sì | Sì (auto se diretta YouTube) |
## Funzionalità implementate
### 1. Registrazione automatica
Pipeline: `Sessions::Stop``FinalizeSession``UploadJob` → storage.
### 2. Retention e purge
`Recordings::PurgeExpiredJob` / `rails recordings:purge_expired`
### 3. Archivio Replay
- Web: `/clubs/:id/replays` (gestione società)
- Pubblico: `/replay` con filtro società/squadra
- App: schermata Replay
### 4. Riproduzione
- Player MP4: `/replay/:id` + stream `/replay/:id/stream`
- Contatore visualizzazioni su ogni play (`view_count`)
### 5. Visibilità
Pubblico / Privato (unlisted), modificabile dalladmin società.
### 6. Eliminazione anticipata
`Recordings::Delete` — DB + video + thumbnail su storage.
### 7. Dashboard KPI
Replay disponibili, in scadenza (7 gg), spazio occupato, **visualizzazioni totali**.
### 8. Miglioramenti Premium
| Feature | Descrizione |
|---------|-------------|
| **Email replay pronto** | A owner e membri società quando status → `ready` |
| **Email scadenza** | 7 giorni prima di `expires_at` (`rails recordings:expiry_warnings`) |
| **Thumbnail** | Frame ffmpeg, URL `/replay/:id/thumbnail` |
| **Download MP4** | Premium, link presigned 15 min o `/replay/:id/download` |
| **Statistiche views** | `view_count` su ogni replay |
| **metadata JSONB** | `source_platform`, `auto_publish_youtube`, `ai: {}` per estensioni |
| **YouTube VOD** | Premium Full: upload automatico se diretta era su YouTube; manuale da archivio |
## Storage
```env
REPLAY_STORAGE_ENDPOINT=http://garage:3900
REPLAY_STORAGE_BUCKET=matchlivetv-replays
REPLAY_STORAGE_ACCESS_KEY_ID=...
REPLAY_STORAGE_SECRET_ACCESS_KEY=...
REPLAY_DOWNLOAD_URL_TTL_SECONDS=900
```
Senza endpoint S3: storage locale `/recordings/replays/`.
### Dev locale con Garage
```bash
cd infra
docker compose up -d # include garage
bash scripts/setup_garage_replays.sh # layout, bucket, chiave → .env
docker compose up -d rails sidekiq
# Verifica pipeline (segmento finto → Garage)
docker compose exec -T rails bundle exec rails replay:e2e_garage
```
**Credenziali in `.env`:** non compilarle a mano in `.env.example`. Lo script scrive in `infra/.env`:
- `REPLAY_STORAGE_ACCESS_KEY_ID` = Key ID Garage (es. `GK...`)
- `REPLAY_STORAGE_SECRET_ACCESS_KEY` = Secret mostrato una sola volta alla creazione chiave
## Cron consigliati (produzione)
```cron
0 3 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:purge_expired
0 8 * * * cd /opt/matchlivetv/infra && docker compose exec -T rails bundle exec rails recordings:expiry_warnings
```
## API mobile
- `GET /teams/:id/recordings` — lista con thumbnail, views, download_enabled
- `GET /recordings/:id/download` — URL download temporaneo
- `POST /recordings/:id/publish_youtube` — coda republicazione YouTube
## Estensioni future (metadata.ai)
Campo `metadata["ai"]` predisposto per:
- highlight automatici
- trascrizioni
- articoli generati
- clip / Shorts
Esempio aggiornamento via API:
```json
{ "recording": { "metadata": { "ai": { "transcript_status": "queued" } } } } }
```

View File

@@ -76,7 +76,7 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
## 5. Verifica ## 5. Verifica
- Premium Full: dopo collegamento, `GET /api/v1/teams/:id``youtube_connected: true`, `youtube_selectable: true` - Premium Full: collegamento OAuth dalla pagina società (`/clubs/:id`); credenziali uniche per club. Dopo collegamento, `GET /api/v1/teams/:id``youtube_connected: true`, `youtube_selectable: true`
- Premium Light: con `YOUTUBE_PLATFORM_REFRESH_TOKEN``youtube_selectable: true` anche senza OAuth squadra - Premium Light: con `YOUTUBE_PLATFORM_REFRESH_TOKEN``youtube_selectable: true` anche senza OAuth squadra
- Avvia diretta YouTube da app → in admin session compare broadcast ID → link YouTube Studio - Avvia diretta YouTube da app → in admin session compare broadcast ID → link YouTube Studio

View File

@@ -26,3 +26,24 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
# Legacy (equivale a YEARLY se le righe sopra sono vuote) # Legacy (equivale a YEARLY se le righe sopra sono vuote)
STRIPE_PREMIUM_LIGHT_PRICE_ID= STRIPE_PREMIUM_LIGHT_PRICE_ID=
STRIPE_PREMIUM_FULL_PRICE_ID= STRIPE_PREMIUM_FULL_PRICE_ID=
# --- Replay storage (dopo ogni diretta Premium) ---
# Dev locale con Docker: Garage parte con `docker compose up` (servizio garage).
# Bootstrap una tantum: bash infra/scripts/setup_garage_replays.sh → aggiorna .env con le chiavi.
# MODALITÀ A — solo filesystem: lascia REPLAY_STORAGE_ENDPOINT vuoto (volume /recordings/replays/).
# MODALITÀ B — Garage/S3: compila tutte le righe sotto (endpoint rete Docker: http://garage:3900).
REPLAY_STORAGE_ENDPOINT=http://garage:3900
REPLAY_STORAGE_BUCKET=matchlivetv-replays
REPLAY_STORAGE_REGION=garage
# Credenziali S3: NON le inventi a mano. Le genera Garage con lo script di bootstrap:
# cd infra && bash scripts/setup_garage_replays.sh
# Lo script crea la chiave "replay-uploader" e scrive Key ID + Secret in infra/.env (non in questo file).
# ACCESS_KEY_ID = "Key ID" mostrato da Garage (es. GK6619e25d53883a7bc3f75755)
# SECRET_ACCESS_KEY = "Secret key" mostrata UNA SOLA VOLTA alla creazione della chiave
REPLAY_STORAGE_ACCESS_KEY_ID=
REPLAY_STORAGE_SECRET_ACCESS_KEY=
# true = obbligatorio per Garage/MinIO (path-style). Non cambiare se usi Garage.
REPLAY_STORAGE_FORCE_PATH_STYLE=true
# Opzionale: durata link firmati per play/download (secondi)
# REPLAY_PRESIGNED_URL_TTL_SECONDS=7200
# REPLAY_DOWNLOAD_URL_TTL_SECONDS=900

View File

@@ -94,6 +94,13 @@ services:
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}} STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
ports: ports:
- "3000:3000" # Solo LAN — NPM proxy HTTPS - "3000:3000" # Solo LAN — NPM proxy HTTPS
depends_on: depends_on:
@@ -133,9 +140,18 @@ services:
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-} YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888} HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
depends_on: depends_on:
rails: rails:
condition: service_healthy condition: service_healthy
volumes:
- recordings:/recordings
volumes: volumes:
postgres_data: postgres_data:

View File

@@ -81,6 +81,13 @@ services:
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}} STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
@@ -90,6 +97,8 @@ services:
condition: service_healthy condition: service_healthy
mediamtx: mediamtx:
condition: service_started condition: service_started
garage:
condition: service_started
volumes: volumes:
- ../backend:/app - ../backend:/app
- recordings:/recordings - recordings:/recordings
@@ -119,13 +128,33 @@ services:
JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret} JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret}
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-} YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-} YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
RECORDINGS_PATH: /recordings
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
depends_on: depends_on:
rails: rails:
condition: service_healthy condition: service_healthy
garage:
condition: service_started
volumes: volumes:
- ../backend:/app - ../backend:/app
- recordings:/recordings
- bundle_cache:/usr/local/bundle - bundle_cache:/usr/local/bundle
garage:
image: dxflrs/garage:v1.0.1
ports:
- "3900:3900"
volumes:
- garage_meta:/var/lib/garage/meta
- garage_data:/var/lib/garage/data
- ./garage/garage.toml:/etc/garage.toml:ro
command: ["/garage", "-c", "/etc/garage.toml", "server"]
nginx: nginx:
image: nginx:alpine image: nginx:alpine
ports: ports:
@@ -140,3 +169,5 @@ volumes:
redis_data: redis_data:
recordings: recordings:
bundle_cache: bundle_cache:
garage_meta:
garage_data:

26
infra/garage/garage.toml Normal file
View File

@@ -0,0 +1,26 @@
# Garage single-node dev config for Match Live TV replay storage
# Docs: https://garagehq.deuxfleurs.fr/documentation/quick-start/
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "garage:3901"
# 32 byte hex (64 caratteri) — solo dev locale
rpc_secret = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
[s3_api]
api_bind_addr = "[::]:3900"
s3_region = "garage"
root_domain = ".s3.garage.localhost"
[s3_web]
bind_addr = "[::]:3902"
root_domain = ".web.garage.localhost"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "dev-garage-admin-token-change-in-production"

View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Bootstrap Garage (S3) per replay Match Live TV — locale o produzione
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
GARAGE_CONTAINER="${GARAGE_CONTAINER:-infra-garage-1}"
BUCKET="${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}"
KEY_NAME="${REPLAY_STORAGE_KEY_NAME:-replay-uploader}"
CREDS_FILE="${ROOT}/garage/dev-credentials.env"
ENV_FILE="${ROOT}/.env"
gexec() {
docker exec "$GARAGE_CONTAINER" /garage "$@"
}
wait_garage() {
echo "Attendo Garage..."
for _ in $(seq 1 30); do
if docker exec "$GARAGE_CONTAINER" /garage status >/dev/null 2>&1; then
return 0
fi
sleep 1
done
echo "Garage non risponde. Avvia: cd $ROOT && docker compose up -d garage"
exit 1
}
ensure_layout() {
local node_id
node_id="$(gexec status 2>/dev/null | awk '/^[0-9a-f]{16,}/{print $1; exit}')"
if [ -z "$node_id" ]; then
echo "Impossibile leggere node id da garage status"
gexec status || true
exit 1
fi
echo "Node id: $node_id"
gexec layout assign -z dc1 -c 1G "$node_id" 2>/dev/null || true
gexec layout apply --version 1 2>/dev/null || gexec layout apply 2>/dev/null || true
}
ensure_key() {
if gexec key info "$KEY_NAME" >/dev/null 2>&1; then
echo "Chiave $KEY_NAME già presente"
return 0
fi
echo "Creazione chiave $KEY_NAME..."
local out
out="$(gexec key create "$KEY_NAME" 2>&1)"
echo "$out"
KEY_SECRET="$(echo "$out" | awk -F': ' '/Secret key/{print $2; exit}' | tr -d ' ')"
if [ -z "$KEY_SECRET" ]; then
KEY_SECRET="$(echo "$out" | grep -i secret | awk '{print $NF}' | head -1)"
fi
export KEY_SECRET
}
ensure_bucket() {
echo "Bucket $BUCKET..."
gexec bucket create "$BUCKET" 2>/dev/null || true
gexec bucket allow \
--read --write \
--owner \
"$BUCKET" \
--key "$KEY_NAME" 2>/dev/null || \
gexec bucket allow --read --write "$BUCKET" --key "$KEY_NAME" 2>/dev/null || true
}
write_credentials() {
local key_id
key_id="$(gexec key info "$KEY_NAME" 2>/dev/null | awk -F': ' '/Key ID|key id/{print $2; exit}' | tr -d ' ')"
if [ -z "$key_id" ]; then
key_id="$(gexec key info "$KEY_NAME" 2>/dev/null | grep -i 'key' | head -1 | awk '{print $NF}')"
fi
mkdir -p "${ROOT}/garage"
cat > "$CREDS_FILE" <<EOF
# Generato da setup_garage_replays.sh — non committare
REPLAY_STORAGE_ENDPOINT=http://garage:3900
REPLAY_STORAGE_BUCKET=${BUCKET}
REPLAY_STORAGE_REGION=garage
REPLAY_STORAGE_ACCESS_KEY_ID=${key_id}
REPLAY_STORAGE_SECRET_ACCESS_KEY=${KEY_SECRET:-}
REPLAY_STORAGE_FORCE_PATH_STYLE=true
EOF
echo ""
echo "=== Credenziali Garage (replay) ==="
gexec key info "$KEY_NAME" || true
echo "File: $CREDS_FILE"
}
merge_into_env() {
local key_id secret
key_id="$(grep '^REPLAY_STORAGE_ACCESS_KEY_ID=' "$CREDS_FILE" | cut -d= -f2-)"
secret="$(grep '^REPLAY_STORAGE_SECRET_ACCESS_KEY=' "$CREDS_FILE" | cut -d= -f2-)"
if [ ! -f "$ENV_FILE" ]; then
cp "${ROOT}/.env.example" "$ENV_FILE" 2>/dev/null || touch "$ENV_FILE"
fi
if grep -q '^REPLAY_STORAGE_ENDPOINT=' "$ENV_FILE" 2>/dev/null; then
sed -i 's|^REPLAY_STORAGE_ENDPOINT=.*|REPLAY_STORAGE_ENDPOINT=http://garage:3900|' "$ENV_FILE"
sed -i "s|^REPLAY_STORAGE_BUCKET=.*|REPLAY_STORAGE_BUCKET=${BUCKET}|" "$ENV_FILE"
sed -i 's|^REPLAY_STORAGE_REGION=.*|REPLAY_STORAGE_REGION=garage|' "$ENV_FILE"
sed -i "s|^REPLAY_STORAGE_ACCESS_KEY_ID=.*|REPLAY_STORAGE_ACCESS_KEY_ID=${key_id}|" "$ENV_FILE"
sed -i 's|^REPLAY_STORAGE_FORCE_PATH_STYLE=.*|REPLAY_STORAGE_FORCE_PATH_STYLE=true|' "$ENV_FILE"
if [ -n "$secret" ]; then
if grep -q '^REPLAY_STORAGE_SECRET_ACCESS_KEY=' "$ENV_FILE" 2>/dev/null; then
sed -i "s|^REPLAY_STORAGE_SECRET_ACCESS_KEY=.*|REPLAY_STORAGE_SECRET_ACCESS_KEY=${secret}|" "$ENV_FILE"
else
echo "REPLAY_STORAGE_SECRET_ACCESS_KEY=${secret}" >> "$ENV_FILE"
fi
fi
else
cat >> "$ENV_FILE" <<EOF
# Replay storage — Garage (generato da setup_garage_replays.sh)
REPLAY_STORAGE_ENDPOINT=http://garage:3900
REPLAY_STORAGE_BUCKET=${BUCKET}
REPLAY_STORAGE_REGION=garage
REPLAY_STORAGE_ACCESS_KEY_ID=${key_id}
REPLAY_STORAGE_SECRET_ACCESS_KEY=${secret}
REPLAY_STORAGE_FORCE_PATH_STYLE=true
EOF
fi
echo "Aggiornato $ENV_FILE (Garage replay storage)."
}
main() {
cd "$ROOT"
if ! docker ps --format '{{.Names}}' | grep -q "^${GARAGE_CONTAINER}$"; then
echo "Avvio container garage..."
docker compose up -d garage
fi
wait_garage
ensure_layout
ensure_key
ensure_bucket
write_credentials
merge_into_env
echo ""
echo "Riavvia app: docker compose up -d rails sidekiq"
}
main "$@"

View File

@@ -5,6 +5,8 @@ import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../app/theme.dart'; import '../../app/theme.dart';
import '../../shared/api_client.dart';
import '../../shared/models/recording_item.dart';
import '../../shared/premium_dialog.dart'; import '../../shared/premium_dialog.dart';
import '../matches/team_providers.dart'; import '../matches/team_providers.dart';
@@ -18,7 +20,7 @@ class ArchiveScreen extends ConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Archivio gare'), title: const Text('Replay'),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/matches'), onPressed: () => context.go('/matches'),
@@ -41,15 +43,21 @@ class ArchiveScreen extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Text( Text(
'Archivio replay con Premium Light o Full', 'Replay disponibili con Premium Light o Full',
style: theme.textTheme.titleMedium, style: theme.textTheme.titleMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 8),
Text(
'Light: 30 giorni · Full: 90 giorni',
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70),
textAlign: TextAlign.center,
),
const SizedBox(height: 16), const SizedBox(height: 16),
FilledButton( FilledButton(
onPressed: () => showPremiumRequiredDialog( onPressed: () => showPremiumRequiredDialog(
context, context,
message: 'Salva e rivedi le gare per 90 giorni con il piano Premium.', message: 'Salva e rivedi le gare con il piano Premium.',
billingUrl: team.billingUrl, billingUrl: team.billingUrl,
), ),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@@ -71,7 +79,15 @@ class ArchiveScreen extends ConsumerWidget {
error: (e, _) => Center(child: Text('$e')), error: (e, _) => Center(child: Text('$e')),
data: (recs) { data: (recs) {
if (recs.isEmpty) { if (recs.isEmpty) {
return const Center(child: Text('Nessuna gara in archivio')); return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.',
textAlign: TextAlign.center,
),
),
);
} }
final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT'); final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT');
return ListView.builder( return ListView.builder(
@@ -79,35 +95,122 @@ class ArchiveScreen extends ConsumerWidget {
itemCount: recs.length, itemCount: recs.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final r = recs[i]; final r = recs[i];
final when = r.recordedAt ?? r.endedAt;
final subtitle = [
if (when != null) df.format(when.toLocal()),
if (r.durationLabel != null) r.durationLabel,
if (r.viewsLabel != null) r.viewsLabel,
r.statusLabel,
if (r.expiresAt != null)
'Scade ${DateFormat('d MMM', 'it_IT').format(r.expiresAt!.toLocal())}',
].whereType<String>().join(' · ');
return Card( return Card(
color: AppTheme.surface, color: AppTheme.surface,
child: ListTile( child: ListTile(
title: Text('${r.teamName} vs ${r.opponentName}'), leading: r.thumbnailUrl != null
subtitle: Text( ? ClipRRect(
r.endedAt != null ? df.format(r.endedAt!.toLocal()) : '', borderRadius: BorderRadius.circular(6),
child: Image.network(
r.thumbnailUrl!,
width: 72,
height: 40,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.movie),
), ),
trailing: const Icon(Icons.play_circle_outline), )
onTap: () async { : const Icon(Icons.movie_outlined, size: 40),
final url = r.replayUrl; title: Text(r.title),
subtitle: Text(subtitle),
isThreeLine: true,
trailing: r.isProcessing
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
: PopupMenuButton<String>(
onSelected: (action) => _onMenuAction(
context,
ref,
action,
r,
team.canDownloadOnPhone,
),
itemBuilder: (_) => [
if (r.isReady)
const PopupMenuItem(
value: 'play',
child: Text('Guarda replay'),
),
if (r.isReady && r.downloadEnabled && team.canDownloadOnPhone)
const PopupMenuItem(
value: 'download',
child: Text('Scarica MP4'),
),
if (r.youtubeWatchUrl != null)
const PopupMenuItem(
value: 'youtube',
child: Text('Apri su YouTube'),
),
],
),
onTap: r.isReady ? () => _openReplay(r.replayUrl) : null,
),
);
},
);
},
);
},
),
);
}
Future<void> _openReplay(String? url) async {
if (url == null) return; if (url == null) return;
final uri = Uri.parse(url); final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl( await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
uri,
mode: team.canDownloadOnPhone
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
);
} }
}, }
),
); Future<void> _onMenuAction(
}, BuildContext context,
); WidgetRef ref,
}, String action,
); RecordingItem r,
}, bool canDownload,
), ) async {
switch (action) {
case 'play':
await _openReplay(r.replayUrl);
break;
case 'download':
if (!canDownload) return;
try {
final url = await ref.read(apiClientProvider).fetchRecordingDownloadUrl(r.id);
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Download: $e')),
); );
} }
} }
break;
case 'youtube':
final yt = r.youtubeWatchUrl;
if (yt == null) return;
final uri = Uri.parse(yt);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
break;
}
}
}

View File

@@ -112,6 +112,13 @@ class ApiClient {
.toList(); .toList();
} }
Future<String> fetchRecordingDownloadUrl(String recordingId) async {
final response = await _dio.get<Map<String, dynamic>>(
'/recordings/$recordingId/download',
);
return response.data!['download_url'] as String;
}
// --- Matches --- // --- Matches ---
Future<List<MatchModel>> fetchMatches(String teamId) async { Future<List<MatchModel>> fetchMatches(String teamId) async {

View File

@@ -2,31 +2,72 @@ class RecordingItem {
const RecordingItem({ const RecordingItem({
required this.id, required this.id,
required this.sessionId, required this.sessionId,
required this.title,
required this.opponentName, required this.opponentName,
required this.teamName, required this.teamName,
this.status = 'ready',
this.statusLabel = 'Disponibile',
this.privacyStatus = 'unlisted',
this.endedAt, this.endedAt,
this.recordedAt,
this.replayUrl, this.replayUrl,
this.playbackUrl,
this.thumbnailUrl,
this.durationLabel,
this.viewCount = 0,
this.viewsLabel,
this.downloadEnabled = false,
this.youtubeWatchUrl,
this.expiresAt, this.expiresAt,
}); });
final String id; final String id;
final String sessionId; final String sessionId;
final String title;
final String opponentName; final String opponentName;
final String teamName; final String teamName;
final String status;
final String statusLabel;
final String privacyStatus;
final DateTime? endedAt; final DateTime? endedAt;
final DateTime? recordedAt;
final String? replayUrl; final String? replayUrl;
final String? playbackUrl;
final String? thumbnailUrl;
final String? durationLabel;
final int viewCount;
final String? viewsLabel;
final bool downloadEnabled;
final String? youtubeWatchUrl;
final DateTime? expiresAt; final DateTime? expiresAt;
bool get isReady => status == 'ready';
bool get isProcessing => status == 'processing';
factory RecordingItem.fromJson(Map<String, dynamic> json) { factory RecordingItem.fromJson(Map<String, dynamic> json) {
return RecordingItem( return RecordingItem(
id: json['id'] as String, id: json['id'] as String,
sessionId: json['session_id'] as String, sessionId: json['session_id'] as String,
title: json['title'] as String? ?? '${json['team_name']} vs ${json['opponent_name']}',
opponentName: json['opponent_name'] as String, opponentName: json['opponent_name'] as String,
teamName: json['team_name'] as String, teamName: json['team_name'] as String,
status: json['status'] as String? ?? 'ready',
statusLabel: json['status_label'] as String? ?? 'Disponibile',
privacyStatus: json['privacy_status'] as String? ?? 'unlisted',
endedAt: json['ended_at'] != null endedAt: json['ended_at'] != null
? DateTime.parse(json['ended_at'] as String) ? DateTime.parse(json['ended_at'] as String)
: null, : null,
recordedAt: json['recorded_at'] != null
? DateTime.parse(json['recorded_at'] as String)
: null,
replayUrl: json['replay_url'] as String?, replayUrl: json['replay_url'] as String?,
playbackUrl: json['playback_url'] as String?,
thumbnailUrl: json['thumbnail_url'] as String?,
durationLabel: json['duration_label'] as String?,
viewCount: json['view_count'] as int? ?? 0,
viewsLabel: json['views_label'] as String?,
downloadEnabled: json['download_enabled'] as bool? ?? false,
youtubeWatchUrl: json['youtube_watch_url'] as String?,
expiresAt: json['expires_at'] != null expiresAt: json['expires_at'] != null
? DateTime.parse(json['expires_at'] as String) ? DateTime.parse(json['expires_at'] as String)
: null, : null,