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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ infra/certs/
|
||||
infra/recordings/
|
||||
infra/.env
|
||||
infra/.env.production.generated
|
||||
infra/garage/dev-credentials.env
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
105
backend/app/controllers/api/v1/recordings_controller.rb
Normal file
105
backend/app/controllers/api/v1/recordings_controller.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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])
|
||||
|
||||
55
backend/app/controllers/public/club_recordings_controller.rb
Normal file
55
backend/app/controllers/public/club_recordings_controller.rb
Normal 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
|
||||
@@ -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?
|
||||
|
||||
@@ -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]
|
||||
|
||||
def index
|
||||
@query = params[:q].to_s.strip
|
||||
@recordings = Recording.ready.publicly_listed
|
||||
.includes(stream_session: { match: { team: :club } })
|
||||
.search_replays(@query)
|
||||
.order(recorded_at: :desc, created_at: :desc)
|
||||
.limit(100)
|
||||
if params[:club_id].present?
|
||||
@recordings = @recordings.joins(:team).where(teams: { club_id: params[:club_id] })
|
||||
end
|
||||
if params[:team_id].present?
|
||||
@recordings = @recordings.where(team_id: params[:team_id])
|
||||
end
|
||||
@clubs = Club.joins(teams: :recordings)
|
||||
.merge(Recording.ready.publicly_listed)
|
||||
.distinct
|
||||
.order(:name)
|
||||
@filter_club = @clubs.find { |c| c.id.to_s == params[:club_id].to_s } if params[:club_id].present?
|
||||
end
|
||||
|
||||
def show
|
||||
@match = @session.match
|
||||
end
|
||||
|
||||
def stream
|
||||
Recordings::IncrementViews.new(@recording).call
|
||||
serve_video(disposition: "inline")
|
||||
end
|
||||
|
||||
def thumbnail
|
||||
serve_thumbnail
|
||||
end
|
||||
|
||||
def download
|
||||
unless download_allowed?
|
||||
redirect_to public_replay_path(@session), alert: "Download non disponibile"
|
||||
return
|
||||
end
|
||||
|
||||
serve_video(disposition: "attachment")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def serve_video(disposition:)
|
||||
Recordings::ServeFromStorage.new(
|
||||
key: @recording.storage_key,
|
||||
content_type: "video/mp4",
|
||||
disposition: disposition,
|
||||
filename: disposition == "attachment" ? download_filename : nil
|
||||
).call(self)
|
||||
rescue ArgumentError
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def serve_thumbnail
|
||||
key = @recording.thumbnail_storage_key
|
||||
return head :not_found if key.blank?
|
||||
|
||||
Recordings::ServeFromStorage.new(
|
||||
key: key,
|
||||
content_type: "image/jpeg",
|
||||
disposition: "inline"
|
||||
).call(self)
|
||||
rescue ArgumentError
|
||||
head :not_found
|
||||
end
|
||||
|
||||
def download_allowed?
|
||||
ent = @recording.team.entitlements
|
||||
return false unless ent.phone_download_enabled?
|
||||
|
||||
Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed? ||
|
||||
Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
|
||||
end
|
||||
|
||||
def download_filename
|
||||
slug = @recording.title_or_default.parameterize.presence || "replay"
|
||||
"#{slug}.mp4"
|
||||
end
|
||||
|
||||
def set_session_and_recording
|
||||
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
|
||||
@recording = Recording.not_deleted.find_by(stream_session: @session)
|
||||
return if @recording
|
||||
|
||||
unless @recording
|
||||
redirect_to public_live_path(@session), alert: "Replay non disponibile"
|
||||
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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
backend/app/jobs/recordings/expiry_warning_job.rb
Normal file
18
backend/app/jobs/recordings/expiry_warning_job.rb
Normal 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
|
||||
18
backend/app/jobs/recordings/post_process_job.rb
Normal file
18
backend/app/jobs/recordings/post_process_job.rb
Normal 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
|
||||
17
backend/app/jobs/recordings/publish_to_youtube_job.rb
Normal file
17
backend/app/jobs/recordings/publish_to_youtube_job.rb
Normal 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
|
||||
14
backend/app/jobs/recordings/purge_expired_job.rb
Normal file
14
backend/app/jobs/recordings/purge_expired_job.rb
Normal 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
|
||||
14
backend/app/jobs/recordings/upload_job.rb
Normal file
14
backend/app/jobs/recordings/upload_job.rb
Normal 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
|
||||
34
backend/app/mailers/recordings/replay_mailer.rb
Normal file
34
backend/app/mailers/recordings/replay_mailer.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class YoutubeCredential < ApplicationRecord
|
||||
belongs_to :team
|
||||
belongs_to :club
|
||||
|
||||
attr_encrypted :access_token,
|
||||
key: :encryption_key,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
33
backend/app/services/recordings/access_policy.rb
Normal file
33
backend/app/services/recordings/access_policy.rb
Normal 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
|
||||
20
backend/app/services/recordings/club_stats.rb
Normal file
20
backend/app/services/recordings/club_stats.rb
Normal 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
|
||||
38
backend/app/services/recordings/delete.rb
Normal file
38
backend/app/services/recordings/delete.rb
Normal 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
|
||||
28
backend/app/services/recordings/download_url.rb
Normal file
28
backend/app/services/recordings/download_url.rb
Normal 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
|
||||
51
backend/app/services/recordings/finalize_session.rb
Normal file
51
backend/app/services/recordings/finalize_session.rb
Normal 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
|
||||
55
backend/app/services/recordings/generate_thumbnail.rb
Normal file
55
backend/app/services/recordings/generate_thumbnail.rb
Normal 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
|
||||
14
backend/app/services/recordings/increment_views.rb
Normal file
14
backend/app/services/recordings/increment_views.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
33
backend/app/services/recordings/notify_expiring.rb
Normal file
33
backend/app/services/recordings/notify_expiring.rb
Normal 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
|
||||
29
backend/app/services/recordings/notify_ready.rb
Normal file
29
backend/app/services/recordings/notify_ready.rb
Normal 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
|
||||
20
backend/app/services/recordings/playback_url.rb
Normal file
20
backend/app/services/recordings/playback_url.rb
Normal 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
|
||||
79
backend/app/services/recordings/publish_to_youtube.rb
Normal file
79
backend/app/services/recordings/publish_to_youtube.rb
Normal 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 l’upload" 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
|
||||
74
backend/app/services/recordings/serve_from_storage.rb
Normal file
74
backend/app/services/recordings/serve_from_storage.rb
Normal 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
|
||||
123
backend/app/services/recordings/storage.rb
Normal file
123
backend/app/services/recordings/storage.rb
Normal 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
|
||||
104
backend/app/services/recordings/upload_from_session.rb
Normal file
104
backend/app/services/recordings/upload_from_session.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
57
backend/app/services/youtube/video_upload_service.rb
Normal file
57
backend/app/services/youtube/video_upload_service.rb
Normal 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
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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, l’app 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 %>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
91
backend/app/views/public/club_recordings/index.html.erb
Normal file
91
backend/app/views/public/club_recordings/index.html.erb
Normal 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>
|
||||
@@ -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» nell’app 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 dall’app 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 %>
|
||||
@@ -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? %>
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
90
backend/app/views/public/replay/index.html.erb
Normal file
90
backend/app/views/public/replay/index.html.erb
Normal 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>
|
||||
@@ -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.status == "processing" %>
|
||||
<div class="card replay-show__status">
|
||||
<p>Registrazione in elaborazione. Riceverai un’email 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 %>
|
||||
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l_local(@recording.expires_at) %></p>
|
||||
<span>· Disponibile fino al <%= l_local(@recording.expires_at) %></span>
|
||||
<% end %>
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
<% if recordings.any? %>
|
||||
<table class="data">
|
||||
<thead><tr><th>Partita</th><th>Replay</th></tr></thead>
|
||||
<thead><tr><th>Partita</th><th>Data</th><th>Durata</th><th>Scadenza</th><th>Stato</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% 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>
|
||||
<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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
16
backend/db/migrate/20260528140000_replay_enhancements.rb
Normal file
16
backend/db/migrate/20260528140000_replay_enhancements.rb
Normal 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
|
||||
@@ -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
31
backend/db/schema.rb
generated
@@ -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
|
||||
|
||||
13
backend/lib/tasks/recordings.rake
Normal file
13
backend/lib/tasks/recordings.rake
Normal 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
|
||||
76
backend/lib/tasks/replay_e2e.rake
Normal file
76
backend/lib/tasks/replay_e2e.rake
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
34
backend/spec/models/recording_spec.rb
Normal file
34
backend/spec/models/recording_spec.rb
Normal 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
|
||||
35
backend/spec/services/recordings/access_policy_spec.rb
Normal file
35
backend/spec/services/recordings/access_policy_spec.rb
Normal 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
|
||||
26
backend/spec/services/recordings/club_stats_spec.rb
Normal file
26
backend/spec/services/recordings/club_stats_spec.rb
Normal 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
|
||||
35
backend/spec/services/recordings/finalize_session_spec.rb
Normal file
35
backend/spec/services/recordings/finalize_session_spec.rb
Normal 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
|
||||
24
backend/spec/services/recordings/increment_views_spec.rb
Normal file
24
backend/spec/services/recordings/increment_views_spec.rb
Normal 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
|
||||
30
backend/spec/services/recordings/notify_ready_spec.rb
Normal file
30
backend/spec/services/recordings/notify_ready_spec.rb
Normal 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
|
||||
@@ -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, l’app 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, l’app 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
105
docs/REPLAY_MODULE.md
Normal 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 dall’admin 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" } } } } }
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
26
infra/garage/garage.toml
Normal 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"
|
||||
143
infra/scripts/setup_garage_replays.sh
Executable file
143
infra/scripts/setup_garage_replays.sh
Executable 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 "$@"
|
||||
@@ -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,35 +95,122 @@ 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()) : '—',
|
||||
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),
|
||||
),
|
||||
trailing: const Icon(Icons.play_circle_outline),
|
||||
onTap: () async {
|
||||
final url = r.replayUrl;
|
||||
)
|
||||
: 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openReplay(String? url) async {
|
||||
if (url == null) return;
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(
|
||||
uri,
|
||||
mode: team.canDownloadOnPhone
|
||||
? LaunchMode.externalApplication
|
||||
: LaunchMode.inAppBrowserView,
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user