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/.env
infra/.env.production.generated
infra/garage/dev-credentials.env
# OS
.DS_Store

View File

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

View File

@@ -81,6 +81,25 @@ GEM
ast (2.4.3)
attr_encrypted (4.2.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)
bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2)
@@ -158,6 +177,7 @@ GEM
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jmespath (1.6.2)
json (2.19.5)
jwt (3.2.0)
base64
@@ -416,6 +436,7 @@ PLATFORMS
DEPENDENCIES
aasm
attr_encrypted
aws-sdk-s3 (~> 1.0)
bcrypt (~> 3.1.7)
bootsnap
brakeman

View File

@@ -10,7 +10,7 @@ module Admin
def show
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.includes(:youtube_credential).order(:name)
@teams = @club.teams.order(:name)
end
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
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) }
end
@@ -87,7 +91,7 @@ module Api
youtube_uses_platform_channel: yt.uses_platform_channel?,
youtube_platform_available: resolver.platform_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_name: ent.plan.name,
premium_active: ent.premium_active?,
@@ -108,7 +112,7 @@ module Api
youtube_mode: ent.plan.youtube_mode,
staff_role: current_user.staff_role_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
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
@@ -121,16 +125,34 @@ module Api
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,
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,
replay_url: recording.replay_url,
download_url: recording.replay_url,
expires_at: recording.expires_at
playback_url: recording.playback_stream_url,
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

View File

@@ -4,6 +4,9 @@ module Api
def authorize
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
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!
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
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
def new
@@ -54,6 +54,7 @@ module Public
@entitlements_team = @club.teams.first
@entitlements = @entitlements_team&.entitlements
@teams = @club.teams.order(:name)
@replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings?
end
def edit
@@ -136,6 +137,33 @@ module Public
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
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
require_club_owner!(@club)
unless MatchLiveTv.stripe_enabled?

View File

@@ -2,14 +2,102 @@ module Public
class ReplayController < SiteBaseController
layout "marketing_live"
def show
@session = StreamSession.includes(match: :team).find(params[:id])
@recording = Recording.ready.find_by(stream_session: @session)
@match = @session.match
before_action :set_session_and_recording, only: %i[show stream thumbnail download]
before_action :authorize_replay_access!, only: %i[show stream thumbnail download]
unless @recording
redirect_to public_live_path(@session), alert: "Replay non disponibile"
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
redirect_to public_live_path(@session), alert: "Replay non disponibile"
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

View File

@@ -116,40 +116,20 @@ module Public
end
def youtube_connect
require_youtube_connect_access!(@team)
@team.entitlements.assert_can_connect_youtube!
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 public_club_youtube_connect_path(
@team.club_id,
return_app: params[: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
def youtube_disconnect
require_club_owner_for_team!(@team)
@team.youtube_credential&.destroy!
redirect_to public_team_details_path(@team), notice: "Canale YouTube scollegato"
@team.club.youtube_credential&.destroy!
redirect_to public_club_path(@team.club), notice: "Canale YouTube della società scollegato"
end
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!
@club = @team.club
@can_manage = @club.owned_by?(current_user)
@@ -160,7 +140,11 @@ module Public
category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player"
)
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)
@staff_memberships = @team.user_teams.includes(:user)
.where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)")

View File

@@ -13,12 +13,12 @@ class YoutubeOauthCallbackController < ActionController::Base
end
user = User.find(ctx[:user_id])
team = Team.find(ctx[:team_id])
unless user.can_stream_for?(team) || team.club&.owned_by?(user)
return redirect_to_oauth_error("Non autorizzato a collegare YouTube per questa squadra")
club = Club.find(ctx[:club_id])
unless club.owned_by?(user)
return redirect_to_oauth_error("Solo il titolare della società può collegare il canale YouTube")
end
cred = team.youtube_credential || team.build_youtube_credential
cred = club.youtube_credential || club.build_youtube_credential
cred.update!(
access_token: tokens[:access_token],
refresh_token: tokens[:refresh_token] || cred.refresh_token,
@@ -28,9 +28,9 @@ class YoutubeOauthCallbackController < ActionController::Base
)
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
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
rescue ArgumentError, Youtube::BroadcastService::Error => e
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 :users, through: :club_memberships
has_many :teams, dependent: :destroy
has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy
has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy
has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy

View File

@@ -1,21 +1,160 @@
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 :team
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_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
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}"
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?
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

View File

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

View File

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

View File

@@ -19,8 +19,10 @@ module Admin
teams_count: Team.count,
users_count: User.count,
matches_count: Match.count,
recordings_count: Recording.count,
recordings_ready: Recording.where(status: "ready").count
recordings_count: Recording.not_deleted.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

View File

@@ -25,6 +25,10 @@ module Mediamtx
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
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?
body[:runOnReady] = run_on_ready_script(session)
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
# @deprecated Use Recordings::FinalizeSession
class IndexSession
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: "ready",
storage_path: @session.mediamtx_path_name,
expires_at: expires_at
)
recording.save!
recording
FinalizeSession.new(@session).call
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)
@session.end_stream!
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
Recordings::IndexSession.new(@session).call
Mediamtx::Client.new.delete_path(@session)
recording = Recordings::FinalizeSession.new(@session).call
if recording
Recordings::UploadJob.perform_async(@session.id)
else
Mediamtx::Client.new.delete_path(@session)
end
log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session

View File

@@ -19,7 +19,7 @@ module Youtube
when :platform
PlatformCredential.configured? ? PlatformCredential.new : nil
when :team
@team.youtube_credential
@team.club.youtube_credential
end
end
@@ -32,7 +32,7 @@ module Youtube
end
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
def effective_channel

View File

@@ -3,12 +3,18 @@ module Youtube
PURPOSE = :youtube_oauth_connect
class << self
def for_team(team_id:, user_id:, return_app: false)
payload = [team_id.to_s, user_id.to_s]
def for_club(club_id:, user_id:, return_app: false)
payload = ["club", club_id.to_s, user_id.to_s]
payload << "app" if return_app
verifier.generate(payload, expires_in: 1.hour)
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:)
verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour)
end
@@ -19,10 +25,21 @@ module Youtube
when Array
if payload.first == "platform"
{ kind: :platform, admin_id: payload[1] }
elsif payload.size >= 2
elsif payload.first == "club" && payload.size >= 3
{
kind: :team,
team_id: payload[0],
kind: :club,
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],
return_app: payload[2] == "app"
}

View File

@@ -15,7 +15,7 @@ module Youtube
when "matchlivetv_light"
PlatformCredential.configured?
when "team"
@team.youtube_credential.present? || PlatformCredential.configured?
@team.club.youtube_credential.present? || PlatformCredential.configured?
else
false
end
@@ -31,7 +31,7 @@ module Youtube
if @mode == "matchlivetv_light"
PlatformCredential.configured?
elsif @mode == "team"
@team.youtube_credential.blank? && PlatformCredential.configured?
@team.club.youtube_credential.blank? && PlatformCredential.configured?
else
false
end
@@ -41,12 +41,12 @@ module Youtube
if uses_platform_channel?
PLATFORM_LABEL
else
@team.youtube_credential&.channel_title
@team.club.youtube_credential&.channel_title
end
end
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

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) %>
<% 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>
<% if @teams.empty? %>
<p class="muted">Nessuna squadra registrata.</p>
@@ -18,32 +30,14 @@
<tr>
<th>Squadra</th>
<th>Sport</th>
<th>YouTube</th>
<th></th>
</tr>
</thead>
<tbody>
<% @teams.each do |team| %>
<% yt = Youtube::TeamStatus.new(team) %>
<tr>
<td><strong><%= team.name %></strong></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>
</tr>
<% end %>

View File

@@ -13,7 +13,7 @@
<% if yt.selectable? %>
<strong><%= yt.channel_title || "—" %></strong>
<% 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 %>
(canale società)
<% end %>
@@ -21,10 +21,10 @@
non pronto
<% end %>
</p>
<% if @team.entitlements.premium_full? && @team.youtube_credential.blank? %>
<% if @team.entitlements.premium_full? && @team.club.youtube_credential.blank? %>
<p class="muted">
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>
<% end %>

View File

@@ -7,7 +7,7 @@
<%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=34">
<link rel="stylesheet" href="/live.css?v=3">
<link rel="stylesheet" href="/live.css?v=4">
<%= yield :head %>
</head>
<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>
<% 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 %>
<% if entitlements.plan.youtube_mode == "matchlivetv_light" %>
<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à.
</p>
<% if yt.selectable? %>
<% if yt&.selectable? %>
<p class="muted">Canale pronto · seleziona «YouTube Live» nellapp mobile.</p>
<% else %>
<p class="muted">Canale Match Live TV in configurazione lato server. Contatta il supporto se YouTube non compare in app.</p>
<% end %>
<% elsif entitlements.premium_full? %>
<% cred = team.youtube_credential %>
<% if cred %>
<p>
Canale collegato:
Canale collegato per <strong>tutta la società</strong>:
<strong><%= cred.channel_title.presence || cred.channel_id || "YouTube" %></strong>
</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,
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." } } %>
@@ -33,13 +34,13 @@
<p>
Senza canale collegato, le dirette YouTube dallapp vanno sul canale ufficiale
<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>
<% if yt.selectable? %>
<p class="muted">YouTube pronto in app (canale Match Live TV).</p>
<% if yt&.selectable? %>
<p class="muted">YouTube pronto in app (canale Match Live TV finché non colleghi il tuo).</p>
<% end %>
<% 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 %>
<p class="muted">OAuth YouTube non ancora configurato sul server.</p>
<% end %>

View File

@@ -22,8 +22,38 @@
<%= 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 "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>
<%= 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>
<div class="card">
<% if @teams.any? %>

View File

@@ -12,11 +12,17 @@
<p class="results-hint">
Solo le squadre di questa società: dirette in corso e partite programmate da app o sito.
</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 %>
<h1>Dirette e partite in programma</h1>
<p class="results-hint">
Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate.
</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 %>
<% 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 :meta_description, "Replay partita #{@match.team.name} vs #{@match.opponent_name} su Match Live TV." %>
<% content_for :robots, "noindex, nofollow" %>
<% content_for :title, "Replay — #{@recording.title_or_default}" %>
<% content_for :meta_description, "Replay #{@recording.title_or_default} su Match Live TV." %>
<% content_for :robots, @recording.publicly_listed? ? "index, follow" : "noindex, nofollow" %>
<div class="wrap">
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
<div class="wrap replay-show">
<%= link_to "← Live passate", public_replay_index_path, class: "back-link" %>
<h1>Replay</h1>
<p style="color:#aaa"><%= @match.team.name %> vs <%= @match.opponent_name %></p>
<header class="replay-show__header">
<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 %>
<div class="card" style="margin:16px 0">
<% if @session.hls_playback_url.present? %>
<video id="replay-player" class="replay-video" controls playsinline></video>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script>
<script>
(function() {
var src = "<%= j @session.hls_playback_url %>";
var video = document.getElementById("replay-player");
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = src;
} else if (typeof Hls !== "undefined" && Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(src);
hls.attachMedia(video);
} 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 %>
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l_local(@recording.expires_at) %></p>
<% end %>
<% if @recording.status == "processing" %>
<div class="card replay-show__status">
<p>Registrazione in elaborazione. Riceverai unemail quando sarà pronta.</p>
</div>
<% elsif @recording.ready? %>
<div class="replay-show__player card">
<video id="replay-player" class="replay-video" controls playsinline preload="metadata"
<% if @recording.thumbnail_url %> poster="<%= @recording.thumbnail_url %>"<% end %>>
<source src="<%= public_replay_stream_path(@session) %>" type="video/mp4">
</video>
</div>
<div class="replay-show__details card">
<p class="replay-show__details-line">
<span>Durata <%= @recording.duration_label %></span>
<% if @recording.expires_at %>
<span>· Disponibile fino al <%= l_local(@recording.expires_at) %></span>
<% 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>
<% end %>
</div>

View File

@@ -81,20 +81,37 @@
</div>
<% end %>
<% if entitlements.can_access_recordings? && recordings.any? %>
<h2>Archivio gare</h2>
<% if entitlements.can_access_recordings? %>
<h2>Archivio replay</h2>
<div class="card">
<table class="data">
<thead><tr><th>Partita</th><th>Replay</th></tr></thead>
<tbody>
<% recordings.each do |rec| %>
<tr>
<td><%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %></td>
<td><%= link_to "Guarda", rec.replay_url %></td>
</tr>
<% end %>
</tbody>
</table>
<% if recordings.any? %>
<table class="data">
<thead><tr><th>Partita</th><th>Data</th><th>Durata</th><th>Scadenza</th><th>Stato</th><th></th></tr></thead>
<tbody>
<% recordings.each do |rec| %>
<tr>
<td><%= rec.title_or_default %></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>
<% end %>
</tbody>
</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>
<% end %>
</section>

View File

@@ -92,7 +92,12 @@
<% end %>
</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,
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 "Termini", public_termini_path %>
</div>
<div class="site-footer__stripe">
<%= render "shared/stripe_secure_payment", compact: true %>
<div class="site-footer__legal">
<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>
</footer>

View File

@@ -96,5 +96,49 @@ module MatchLiveTv
def password_reset_expiry_hours
ENV.fetch("PASSWORD_RESET_EXPIRY_HOURS", "2").to_i
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

View File

@@ -24,6 +24,13 @@ Rails.application.routes.draw do
get "youtube/authorize", to: "youtube#authorize"
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 :sessions, only: %i[create], controller: "stream_sessions"
end
@@ -87,7 +94,11 @@ Rails.application.routes.draw do
get "live", to: "public/live#index", as: :public_live_index
get "live/:id", to: "public/live#show", as: :public_live
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/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/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
patch "clubs/:id", to: "clubs#update"
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
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
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
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
post "clubs/:club_id/matches", to: "club_matches#create", as: :club_matches
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.
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
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -176,9 +176,32 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
t.datetime "expires_at"
t.datetime "created_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 ["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 ["team_id", "status"], name: "index_recordings_on_team_id_and_status"
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
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
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 "refresh_token_encrypted"
t.datetime "expires_at"
@@ -342,7 +364,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
t.string "channel_title"
t.datetime "created_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
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 "user_teams", "teams"
add_foreign_key "user_teams", "users"
add_foreign_key "youtube_credentials", "teams"
add_foreign_key "youtube_credentials", "clubs"
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;
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; }
.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__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 th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
.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 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
@@ -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:
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
## 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
- 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
- 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)
STRIPE_PREMIUM_LIGHT_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_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_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:
- "3000:3000" # Solo LAN — NPM proxy HTTPS
depends_on:
@@ -133,9 +140,18 @@ services:
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
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:
rails:
condition: service_healthy
volumes:
- recordings:/recordings
volumes:
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_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_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:
- "3000:3000"
depends_on:
@@ -90,6 +97,8 @@ services:
condition: service_healthy
mediamtx:
condition: service_started
garage:
condition: service_started
volumes:
- ../backend:/app
- recordings:/recordings
@@ -119,13 +128,33 @@ services:
JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret}
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
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:
rails:
condition: service_healthy
garage:
condition: service_started
volumes:
- ../backend:/app
- recordings:/recordings
- 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:
image: nginx:alpine
ports:
@@ -140,3 +169,5 @@ volumes:
redis_data:
recordings:
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 '../../app/theme.dart';
import '../../shared/api_client.dart';
import '../../shared/models/recording_item.dart';
import '../../shared/premium_dialog.dart';
import '../matches/team_providers.dart';
@@ -18,7 +20,7 @@ class ArchiveScreen extends ConsumerWidget {
return Scaffold(
appBar: AppBar(
title: const Text('Archivio gare'),
title: const Text('Replay'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/matches'),
@@ -41,15 +43,21 @@ class ArchiveScreen extends ConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Archivio replay con Premium Light o Full',
'Replay disponibili con Premium Light o Full',
style: theme.textTheme.titleMedium,
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),
FilledButton(
onPressed: () => showPremiumRequiredDialog(
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,
),
style: FilledButton.styleFrom(
@@ -71,7 +79,15 @@ class ArchiveScreen extends ConsumerWidget {
error: (e, _) => Center(child: Text('$e')),
data: (recs) {
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');
return ListView.builder(
@@ -79,27 +95,68 @@ class ArchiveScreen extends ConsumerWidget {
itemCount: recs.length,
itemBuilder: (context, 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(
color: AppTheme.surface,
child: ListTile(
title: Text('${r.teamName} vs ${r.opponentName}'),
subtitle: Text(
r.endedAt != null ? df.format(r.endedAt!.toLocal()) : '',
),
trailing: const Icon(Icons.play_circle_outline),
onTap: () async {
final url = r.replayUrl;
if (url == null) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(
uri,
mode: team.canDownloadOnPhone
? LaunchMode.externalApplication
: LaunchMode.inAppBrowserView,
);
}
},
leading: r.thumbnailUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.network(
r.thumbnailUrl!,
width: 72,
height: 40,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.movie),
),
)
: const Icon(Icons.movie_outlined, size: 40),
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,
),
);
},
@@ -110,4 +167,50 @@ class ArchiveScreen extends ConsumerWidget {
),
);
}
Future<void> _openReplay(String? url) async {
if (url == null) return;
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: 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();
}
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 ---
Future<List<MatchModel>> fetchMatches(String teamId) async {

View File

@@ -2,31 +2,72 @@ class RecordingItem {
const RecordingItem({
required this.id,
required this.sessionId,
required this.title,
required this.opponentName,
required this.teamName,
this.status = 'ready',
this.statusLabel = 'Disponibile',
this.privacyStatus = 'unlisted',
this.endedAt,
this.recordedAt,
this.replayUrl,
this.playbackUrl,
this.thumbnailUrl,
this.durationLabel,
this.viewCount = 0,
this.viewsLabel,
this.downloadEnabled = false,
this.youtubeWatchUrl,
this.expiresAt,
});
final String id;
final String sessionId;
final String title;
final String opponentName;
final String teamName;
final String status;
final String statusLabel;
final String privacyStatus;
final DateTime? endedAt;
final DateTime? recordedAt;
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;
bool get isReady => status == 'ready';
bool get isProcessing => status == 'processing';
factory RecordingItem.fromJson(Map<String, dynamic> json) {
return RecordingItem(
id: json['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,
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
? DateTime.parse(json['ended_at'] as String)
: null,
recordedAt: json['recorded_at'] != null
? DateTime.parse(json['recorded_at'] as String)
: null,
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
? DateTime.parse(json['expires_at'] as String)
: null,