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/recordings/
|
||||||
infra/.env
|
infra/.env
|
||||||
infra/.env.production.generated
|
infra/.env.production.generated
|
||||||
|
infra/garage/dev-credentials.env
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ gem "attr_encrypted"
|
|||||||
gem "google-apis-youtube_v3"
|
gem "google-apis-youtube_v3"
|
||||||
gem "faraday"
|
gem "faraday"
|
||||||
gem "stripe"
|
gem "stripe"
|
||||||
|
gem "aws-sdk-s3", "~> 1.0", require: false
|
||||||
gem "kamal", require: false
|
gem "kamal", require: false
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ GEM
|
|||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_encrypted (4.2.0)
|
attr_encrypted (4.2.0)
|
||||||
encryptor (~> 3.0.0)
|
encryptor (~> 3.0.0)
|
||||||
|
aws-eventstream (1.4.0)
|
||||||
|
aws-partitions (1.1256.0)
|
||||||
|
aws-sdk-core (3.251.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
|
aws-sigv4 (~> 1.9)
|
||||||
|
base64
|
||||||
|
bigdecimal
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
logger
|
||||||
|
aws-sdk-kms (1.129.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.248.0)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
aws-sdk-s3 (1.225.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.248.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.5)
|
||||||
|
aws-sigv4 (1.12.1)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
base64 (0.3.0)
|
base64 (0.3.0)
|
||||||
bcrypt (3.1.22)
|
bcrypt (3.1.22)
|
||||||
bcrypt_pbkdf (1.1.2)
|
bcrypt_pbkdf (1.1.2)
|
||||||
@@ -158,6 +177,7 @@ GEM
|
|||||||
prism (>= 1.3.0)
|
prism (>= 1.3.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
|
jmespath (1.6.2)
|
||||||
json (2.19.5)
|
json (2.19.5)
|
||||||
jwt (3.2.0)
|
jwt (3.2.0)
|
||||||
base64
|
base64
|
||||||
@@ -416,6 +436,7 @@ PLATFORMS
|
|||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
aasm
|
aasm
|
||||||
attr_encrypted
|
attr_encrypted
|
||||||
|
aws-sdk-s3 (~> 1.0)
|
||||||
bcrypt (~> 3.1.7)
|
bcrypt (~> 3.1.7)
|
||||||
bootsnap
|
bootsnap
|
||||||
brakeman
|
brakeman
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ module Admin
|
|||||||
def show
|
def show
|
||||||
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
|
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
|
||||||
@plans = Plan.ordered.reject { |p| p.slug == "free" }
|
@plans = Plan.ordered.reject { |p| p.slug == "free" }
|
||||||
@teams = @club.teams.includes(:youtube_credential).order(:name)
|
@teams = @club.teams.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def grant_comped
|
def grant_comped
|
||||||
|
|||||||
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
|
}, status: :forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
recs = team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(50)
|
recs = team.recordings.not_deleted
|
||||||
|
.where(status: %w[ready processing])
|
||||||
|
.includes(stream_session: :match)
|
||||||
|
.order(recorded_at: :desc, created_at: :desc)
|
||||||
|
.limit(50)
|
||||||
render json: recs.map { |r| recording_json(r) }
|
render json: recs.map { |r| recording_json(r) }
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -87,7 +91,7 @@ module Api
|
|||||||
youtube_uses_platform_channel: yt.uses_platform_channel?,
|
youtube_uses_platform_channel: yt.uses_platform_channel?,
|
||||||
youtube_platform_available: resolver.platform_available?,
|
youtube_platform_available: resolver.platform_available?,
|
||||||
youtube_team_channel_available: resolver.team_channel_available?,
|
youtube_team_channel_available: resolver.team_channel_available?,
|
||||||
youtube_team_channel_title: team.youtube_credential&.channel_title,
|
youtube_team_channel_title: team.club.youtube_credential&.channel_title,
|
||||||
plan_slug: ent.plan.slug,
|
plan_slug: ent.plan.slug,
|
||||||
plan_name: ent.plan.name,
|
plan_name: ent.plan.name,
|
||||||
premium_active: ent.premium_active?,
|
premium_active: ent.premium_active?,
|
||||||
@@ -108,7 +112,7 @@ module Api
|
|||||||
youtube_mode: ent.plan.youtube_mode,
|
youtube_mode: ent.plan.youtube_mode,
|
||||||
staff_role: current_user.staff_role_for(team),
|
staff_role: current_user.staff_role_for(team),
|
||||||
can_stream: current_user.can_stream_for?(team),
|
can_stream: current_user.can_stream_for?(team),
|
||||||
can_connect_youtube: current_user.can_stream_for?(team) && ent.premium_full? && ent.youtube_enabled?
|
can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled?
|
||||||
}
|
}
|
||||||
if detail
|
if detail
|
||||||
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
|
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
|
||||||
@@ -121,16 +125,34 @@ module Api
|
|||||||
def recording_json(recording)
|
def recording_json(recording)
|
||||||
session = recording.stream_session
|
session = recording.stream_session
|
||||||
match = session.match
|
match = session.match
|
||||||
|
ent = recording.team.entitlements
|
||||||
{
|
{
|
||||||
id: recording.id,
|
id: recording.id,
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
match_id: match.id,
|
match_id: match.id,
|
||||||
|
title: recording.title_or_default,
|
||||||
opponent_name: match.opponent_name,
|
opponent_name: match.opponent_name,
|
||||||
team_name: match.team.name,
|
team_name: match.team.name,
|
||||||
|
status: recording.status,
|
||||||
|
status_label: recording.status_label,
|
||||||
|
privacy_status: recording.privacy_status,
|
||||||
ended_at: session.ended_at,
|
ended_at: session.ended_at,
|
||||||
|
recorded_at: recording.recorded_at_or_fallback,
|
||||||
|
duration_secs: recording.duration_secs,
|
||||||
|
duration_label: recording.duration_label,
|
||||||
|
byte_size: recording.byte_size,
|
||||||
|
byte_size_label: recording.byte_size_label,
|
||||||
replay_url: recording.replay_url,
|
replay_url: recording.replay_url,
|
||||||
download_url: recording.replay_url,
|
playback_url: recording.playback_stream_url,
|
||||||
expires_at: recording.expires_at
|
thumbnail_url: recording.thumbnail_url,
|
||||||
|
download_enabled: ent.phone_download_enabled?,
|
||||||
|
view_count: recording.view_count,
|
||||||
|
views_label: recording.views_label,
|
||||||
|
youtube_video_id: recording.youtube_video_id,
|
||||||
|
youtube_watch_url: recording.youtube_watch_url,
|
||||||
|
youtube_publish_enabled: ent.premium_full? && ent.youtube_enabled?,
|
||||||
|
expires_at: recording.expires_at,
|
||||||
|
metadata: recording.metadata
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ module Api
|
|||||||
def authorize
|
def authorize
|
||||||
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
|
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
|
||||||
raise ActiveRecord::RecordNotFound unless team
|
raise ActiveRecord::RecordNotFound unless team
|
||||||
|
unless team.club.owned_by?(current_user)
|
||||||
|
return render json: { error: "Solo il titolare della società può collegare YouTube" }, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
team.entitlements.assert_can_connect_youtube!
|
team.entitlements.assert_can_connect_youtube!
|
||||||
return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app])
|
return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app])
|
||||||
|
|||||||
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
|
include BrandingAttachments
|
||||||
|
|
||||||
before_action :require_login!
|
before_action :require_login!
|
||||||
before_action :set_club, only: %i[show edit update billing checkout portal]
|
before_action :set_club, only: %i[show edit update billing checkout portal youtube_connect youtube_disconnect]
|
||||||
before_action :require_billing_profile_for_premium_checkout!, only: :checkout
|
before_action :require_billing_profile_for_premium_checkout!, only: :checkout
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@@ -54,6 +54,7 @@ module Public
|
|||||||
@entitlements_team = @club.teams.first
|
@entitlements_team = @club.teams.first
|
||||||
@entitlements = @entitlements_team&.entitlements
|
@entitlements = @entitlements_team&.entitlements
|
||||||
@teams = @club.teams.order(:name)
|
@teams = @club.teams.order(:name)
|
||||||
|
@replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings?
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@@ -136,6 +137,33 @@ module Public
|
|||||||
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
|
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def youtube_connect
|
||||||
|
require_club_owner!(@club)
|
||||||
|
entitlements = @club.teams.first!.entitlements
|
||||||
|
entitlements.assert_can_connect_youtube!
|
||||||
|
|
||||||
|
if ENV["YOUTUBE_CLIENT_ID"].blank?
|
||||||
|
redirect_to public_club_path(@club), alert: "YouTube OAuth non configurato sul server"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
return_app = params[:return_app] == "1"
|
||||||
|
state = Youtube::OauthState.for_club(
|
||||||
|
club_id: @club.id,
|
||||||
|
user_id: current_user.id,
|
||||||
|
return_app: return_app
|
||||||
|
)
|
||||||
|
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
|
||||||
|
rescue Teams::EntitlementError => e
|
||||||
|
redirect_to public_club_path(@club), alert: e.message
|
||||||
|
end
|
||||||
|
|
||||||
|
def youtube_disconnect
|
||||||
|
require_club_owner!(@club)
|
||||||
|
@club.youtube_credential&.destroy!
|
||||||
|
redirect_to public_club_path(@club), notice: "Canale YouTube della società scollegato"
|
||||||
|
end
|
||||||
|
|
||||||
def portal
|
def portal
|
||||||
require_club_owner!(@club)
|
require_club_owner!(@club)
|
||||||
unless MatchLiveTv.stripe_enabled?
|
unless MatchLiveTv.stripe_enabled?
|
||||||
|
|||||||
@@ -2,14 +2,102 @@ module Public
|
|||||||
class ReplayController < SiteBaseController
|
class ReplayController < SiteBaseController
|
||||||
layout "marketing_live"
|
layout "marketing_live"
|
||||||
|
|
||||||
def show
|
before_action :set_session_and_recording, only: %i[show stream thumbnail download]
|
||||||
@session = StreamSession.includes(match: :team).find(params[:id])
|
before_action :authorize_replay_access!, only: %i[show stream thumbnail download]
|
||||||
@recording = Recording.ready.find_by(stream_session: @session)
|
|
||||||
@match = @session.match
|
|
||||||
|
|
||||||
unless @recording
|
def index
|
||||||
redirect_to public_live_path(@session), alert: "Replay non disponibile"
|
@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
|
end
|
||||||
|
if params[:team_id].present?
|
||||||
|
@recordings = @recordings.where(team_id: params[:team_id])
|
||||||
|
end
|
||||||
|
@clubs = Club.joins(teams: :recordings)
|
||||||
|
.merge(Recording.ready.publicly_listed)
|
||||||
|
.distinct
|
||||||
|
.order(:name)
|
||||||
|
@filter_club = @clubs.find { |c| c.id.to_s == params[:club_id].to_s } if params[:club_id].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@match = @session.match
|
||||||
|
end
|
||||||
|
|
||||||
|
def stream
|
||||||
|
Recordings::IncrementViews.new(@recording).call
|
||||||
|
serve_video(disposition: "inline")
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail
|
||||||
|
serve_thumbnail
|
||||||
|
end
|
||||||
|
|
||||||
|
def download
|
||||||
|
unless download_allowed?
|
||||||
|
redirect_to public_replay_path(@session), alert: "Download non disponibile"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
serve_video(disposition: "attachment")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def serve_video(disposition:)
|
||||||
|
Recordings::ServeFromStorage.new(
|
||||||
|
key: @recording.storage_key,
|
||||||
|
content_type: "video/mp4",
|
||||||
|
disposition: disposition,
|
||||||
|
filename: disposition == "attachment" ? download_filename : nil
|
||||||
|
).call(self)
|
||||||
|
rescue ArgumentError
|
||||||
|
head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def serve_thumbnail
|
||||||
|
key = @recording.thumbnail_storage_key
|
||||||
|
return head :not_found if key.blank?
|
||||||
|
|
||||||
|
Recordings::ServeFromStorage.new(
|
||||||
|
key: key,
|
||||||
|
content_type: "image/jpeg",
|
||||||
|
disposition: "inline"
|
||||||
|
).call(self)
|
||||||
|
rescue ArgumentError
|
||||||
|
head :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_allowed?
|
||||||
|
ent = @recording.team.entitlements
|
||||||
|
return false unless ent.phone_download_enabled?
|
||||||
|
|
||||||
|
Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed? ||
|
||||||
|
Recordings::AccessPolicy.new(@recording, viewer: current_user).manage_allowed?
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_filename
|
||||||
|
slug = @recording.title_or_default.parameterize.presence || "replay"
|
||||||
|
"#{slug}.mp4"
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_session_and_recording
|
||||||
|
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
|
||||||
|
@recording = Recording.not_deleted.find_by(stream_session: @session)
|
||||||
|
return if @recording
|
||||||
|
|
||||||
|
redirect_to public_live_path(@session), alert: "Replay non disponibile"
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_replay_access!
|
||||||
|
return if Recordings::AccessPolicy.new(@recording, viewer: current_user).allowed?
|
||||||
|
|
||||||
|
redirect_to public_live_index_path, alert: "Replay non disponibile"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -116,40 +116,20 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
def youtube_connect
|
def youtube_connect
|
||||||
require_youtube_connect_access!(@team)
|
redirect_to public_club_youtube_connect_path(
|
||||||
@team.entitlements.assert_can_connect_youtube!
|
@team.club_id,
|
||||||
|
return_app: params[:return_app]
|
||||||
if ENV["YOUTUBE_CLIENT_ID"].blank?
|
|
||||||
redirect_to public_team_details_path(@team), alert: "YouTube OAuth non configurato sul server"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
return_app = params[:return_app] == "1"
|
|
||||||
state = Youtube::OauthState.for_team(
|
|
||||||
team_id: @team.id,
|
|
||||||
user_id: current_user.id,
|
|
||||||
return_app: return_app
|
|
||||||
)
|
)
|
||||||
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
|
|
||||||
rescue Teams::EntitlementError => e
|
|
||||||
redirect_to public_team_details_path(@team), alert: e.message
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def youtube_disconnect
|
def youtube_disconnect
|
||||||
require_club_owner_for_team!(@team)
|
require_club_owner_for_team!(@team)
|
||||||
@team.youtube_credential&.destroy!
|
@team.club.youtube_credential&.destroy!
|
||||||
redirect_to public_team_details_path(@team), notice: "Canale YouTube scollegato"
|
redirect_to public_club_path(@team.club), notice: "Canale YouTube della società scollegato"
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def require_youtube_connect_access!(team)
|
|
||||||
return if team.club&.owned_by?(current_user)
|
|
||||||
return if current_user.can_stream_for?(team)
|
|
||||||
|
|
||||||
redirect_to public_team_details_path(team), alert: "Accesso non consentito"
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_team_details!
|
def load_team_details!
|
||||||
@club = @team.club
|
@club = @team.club
|
||||||
@can_manage = @club.owned_by?(current_user)
|
@can_manage = @club.owned_by?(current_user)
|
||||||
@@ -160,7 +140,11 @@ module Public
|
|||||||
category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player"
|
category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player"
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
|
@recordings = @team.recordings.not_deleted
|
||||||
|
.where(status: %w[ready processing])
|
||||||
|
.includes(stream_session: :match)
|
||||||
|
.order(recorded_at: :desc, created_at: :desc)
|
||||||
|
.limit(10)
|
||||||
@owner_membership = current_user.user_teams.find_by(team: @team)
|
@owner_membership = current_user.user_teams.find_by(team: @team)
|
||||||
@staff_memberships = @team.user_teams.includes(:user)
|
@staff_memberships = @team.user_teams.includes(:user)
|
||||||
.where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)")
|
.where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)")
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ class YoutubeOauthCallbackController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
user = User.find(ctx[:user_id])
|
user = User.find(ctx[:user_id])
|
||||||
team = Team.find(ctx[:team_id])
|
club = Club.find(ctx[:club_id])
|
||||||
unless user.can_stream_for?(team) || team.club&.owned_by?(user)
|
unless club.owned_by?(user)
|
||||||
return redirect_to_oauth_error("Non autorizzato a collegare YouTube per questa squadra")
|
return redirect_to_oauth_error("Solo il titolare della società può collegare il canale YouTube")
|
||||||
end
|
end
|
||||||
|
|
||||||
cred = team.youtube_credential || team.build_youtube_credential
|
cred = club.youtube_credential || club.build_youtube_credential
|
||||||
cred.update!(
|
cred.update!(
|
||||||
access_token: tokens[:access_token],
|
access_token: tokens[:access_token],
|
||||||
refresh_token: tokens[:refresh_token] || cred.refresh_token,
|
refresh_token: tokens[:refresh_token] || cred.refresh_token,
|
||||||
@@ -28,9 +28,9 @@ class YoutubeOauthCallbackController < ActionController::Base
|
|||||||
)
|
)
|
||||||
|
|
||||||
if ctx[:return_app]
|
if ctx[:return_app]
|
||||||
redirect_to "matchlivetv://youtube-connected?team_id=#{team.id}", allow_other_host: true
|
redirect_to "matchlivetv://youtube-connected?club_id=#{club.id}", allow_other_host: true
|
||||||
else
|
else
|
||||||
redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true
|
redirect_to "#{public_base_url}/clubs/#{club.id}?youtube=connected", allow_other_host: true
|
||||||
end
|
end
|
||||||
rescue ArgumentError, Youtube::BroadcastService::Error => e
|
rescue ArgumentError, Youtube::BroadcastService::Error => e
|
||||||
redirect_to_oauth_error(e.message)
|
redirect_to_oauth_error(e.message)
|
||||||
|
|||||||
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 :club_memberships, dependent: :destroy
|
||||||
has_many :users, through: :club_memberships
|
has_many :users, through: :club_memberships
|
||||||
has_many :teams, dependent: :destroy
|
has_many :teams, dependent: :destroy
|
||||||
|
has_one :youtube_credential, dependent: :destroy
|
||||||
has_one :subscription, dependent: :destroy
|
has_one :subscription, dependent: :destroy
|
||||||
has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy
|
has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy
|
||||||
has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy
|
has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy
|
||||||
|
|||||||
@@ -1,21 +1,160 @@
|
|||||||
class Recording < ApplicationRecord
|
class Recording < ApplicationRecord
|
||||||
STATUSES = %w[processing ready expired].freeze
|
STATUSES = %w[processing ready expired failed].freeze
|
||||||
|
PRIVACY_STATUSES = %w[public unlisted].freeze
|
||||||
|
STORAGE_BACKENDS = %w[local s3].freeze
|
||||||
|
|
||||||
belongs_to :stream_session
|
belongs_to :stream_session
|
||||||
belongs_to :team
|
belongs_to :team
|
||||||
|
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
|
validates :privacy_status, inclusion: { in: PRIVACY_STATUSES }
|
||||||
|
validates :storage_backend, inclusion: { in: STORAGE_BACKENDS }
|
||||||
|
|
||||||
scope :ready, -> { where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current) }
|
scope :not_deleted, -> { where(deleted_at: nil) }
|
||||||
|
scope :ready, lambda {
|
||||||
|
not_deleted.where(status: "ready").where("expires_at IS NULL OR expires_at > ?", Time.current)
|
||||||
|
}
|
||||||
|
scope :publicly_listed, -> { where(privacy_status: "public") }
|
||||||
scope :for_team, ->(team) { where(team: team) }
|
scope :for_team, ->(team) { where(team: team) }
|
||||||
|
scope :for_club, lambda { |club|
|
||||||
|
joins(:team).where(teams: { club_id: club.id })
|
||||||
|
}
|
||||||
|
scope :expiring_within, lambda { |days|
|
||||||
|
ready.where(expires_at: ..days.days.from_now)
|
||||||
|
}
|
||||||
|
scope :expired_pending_purge, lambda {
|
||||||
|
not_deleted.where(status: %w[ready failed]).where("expires_at IS NOT NULL AND expires_at <= ?", Time.current)
|
||||||
|
}
|
||||||
|
scope :search_replays, lambda { |query|
|
||||||
|
q = query.to_s.strip
|
||||||
|
return all if q.blank?
|
||||||
|
|
||||||
|
term = "%#{sanitize_sql_like(q)}%"
|
||||||
|
joins(stream_session: { match: { team: :club } }).where(
|
||||||
|
"recordings.title ILIKE :term OR teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
|
||||||
|
term: term
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
before_validation :normalize_privacy_status
|
||||||
|
|
||||||
def replay_url
|
def replay_url
|
||||||
return nil unless ready? && stream_session_id.present?
|
return nil unless stream_session_id.present?
|
||||||
|
return nil unless ready? || status == "processing"
|
||||||
|
|
||||||
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}"
|
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def playback_stream_url
|
||||||
|
return nil unless ready? && stream_session_id.present?
|
||||||
|
|
||||||
|
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/stream"
|
||||||
|
end
|
||||||
|
|
||||||
|
def thumbnail_url
|
||||||
|
return nil unless thumbnail_storage_key.present? && stream_session_id.present?
|
||||||
|
|
||||||
|
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{stream_session_id}/thumbnail"
|
||||||
|
end
|
||||||
|
|
||||||
|
def download_api_path
|
||||||
|
return nil unless ready?
|
||||||
|
|
||||||
|
"/api/v1/recordings/#{id}/download"
|
||||||
|
end
|
||||||
|
|
||||||
|
def youtube_watch_url
|
||||||
|
return nil if youtube_video_id.blank?
|
||||||
|
return nil if youtube_video_id.to_s.start_with?("mock_")
|
||||||
|
|
||||||
|
"https://www.youtube.com/watch?v=#{youtube_video_id}"
|
||||||
|
end
|
||||||
|
|
||||||
def ready?
|
def ready?
|
||||||
status == "ready" && (expires_at.nil? || expires_at.future?)
|
status == "ready" && !deleted? && (expires_at.nil? || expires_at.future?)
|
||||||
|
end
|
||||||
|
|
||||||
|
def deleted?
|
||||||
|
deleted_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def publicly_listed?
|
||||||
|
privacy_status == "public"
|
||||||
|
end
|
||||||
|
|
||||||
|
def unlisted?
|
||||||
|
privacy_status == "unlisted"
|
||||||
|
end
|
||||||
|
|
||||||
|
def auto_publish_youtube?
|
||||||
|
metadata.is_a?(Hash) && metadata["auto_publish_youtube"] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
def ai_metadata
|
||||||
|
metadata.fetch("ai", {})
|
||||||
|
end
|
||||||
|
|
||||||
|
def merge_metadata!(attrs)
|
||||||
|
update!(metadata: metadata.merge(attrs.stringify_keys))
|
||||||
|
end
|
||||||
|
|
||||||
|
def title_or_default
|
||||||
|
title.presence || default_title
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_title
|
||||||
|
match = stream_session&.match
|
||||||
|
return "Replay" unless match
|
||||||
|
|
||||||
|
"#{match.team.name} vs #{match.opponent_name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def recorded_at_or_fallback
|
||||||
|
recorded_at || stream_session&.ended_at || stream_session&.started_at || created_at
|
||||||
|
end
|
||||||
|
|
||||||
|
def duration_label
|
||||||
|
return "—" if duration_secs.to_i <= 0
|
||||||
|
|
||||||
|
mins = duration_secs / 60
|
||||||
|
secs = duration_secs % 60
|
||||||
|
format("%d:%02d", mins, secs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def byte_size_label
|
||||||
|
bytes = byte_size.to_i
|
||||||
|
return "—" if bytes <= 0
|
||||||
|
|
||||||
|
if bytes >= 1.gigabyte
|
||||||
|
format("%.1f GB", bytes / 1.gigabyte.to_f)
|
||||||
|
elsif bytes >= 1.megabyte
|
||||||
|
format("%.1f MB", bytes / 1.megabyte.to_f)
|
||||||
|
else
|
||||||
|
format("%.0f KB", bytes / 1.kilobyte.to_f)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def views_label
|
||||||
|
count = view_count.to_i
|
||||||
|
return "0 visualizzazioni" if count.zero?
|
||||||
|
|
||||||
|
count == 1 ? "1 visualizzazione" : "#{count} visualizzazioni"
|
||||||
|
end
|
||||||
|
|
||||||
|
def status_label
|
||||||
|
return "Eliminato" if deleted?
|
||||||
|
return "Scaduto" if status == "expired"
|
||||||
|
return "Errore" if status == "failed"
|
||||||
|
return "In elaborazione" if status == "processing"
|
||||||
|
return "Scade presto" if expires_at.present? && expires_at <= 7.days.from_now
|
||||||
|
|
||||||
|
"Disponibile"
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_privacy_status
|
||||||
|
self.privacy_status = "public" if privacy_status == "private"
|
||||||
|
self.privacy_status = "unlisted" if privacy_status.blank?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ class Team < ApplicationRecord
|
|||||||
has_many :user_teams, dependent: :destroy
|
has_many :user_teams, dependent: :destroy
|
||||||
has_many :users, through: :user_teams
|
has_many :users, through: :user_teams
|
||||||
has_many :matches, dependent: :destroy
|
has_many :matches, dependent: :destroy
|
||||||
has_one :youtube_credential, dependent: :destroy
|
|
||||||
has_many :recordings, dependent: :destroy
|
has_many :recordings, dependent: :destroy
|
||||||
has_many :team_invitations, dependent: :destroy
|
has_many :team_invitations, dependent: :destroy
|
||||||
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
|
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
|
||||||
@@ -23,6 +22,11 @@ class Team < ApplicationRecord
|
|||||||
Teams::Entitlements.new(self)
|
Teams::Entitlements.new(self)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Canale YouTube OAuth è unico per tutta la società (Premium Full).
|
||||||
|
def youtube_credential
|
||||||
|
club&.youtube_credential
|
||||||
|
end
|
||||||
|
|
||||||
def owner
|
def owner
|
||||||
club&.owner
|
club&.owner
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
class YoutubeCredential < ApplicationRecord
|
class YoutubeCredential < ApplicationRecord
|
||||||
belongs_to :team
|
belongs_to :club
|
||||||
|
|
||||||
attr_encrypted :access_token,
|
attr_encrypted :access_token,
|
||||||
key: :encryption_key,
|
key: :encryption_key,
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ module Admin
|
|||||||
teams_count: Team.count,
|
teams_count: Team.count,
|
||||||
users_count: User.count,
|
users_count: User.count,
|
||||||
matches_count: Match.count,
|
matches_count: Match.count,
|
||||||
recordings_count: Recording.count,
|
recordings_count: Recording.not_deleted.count,
|
||||||
recordings_ready: Recording.where(status: "ready").count
|
recordings_ready: Recording.ready.count,
|
||||||
|
recordings_bytes: Recording.ready.sum(:byte_size).to_i,
|
||||||
|
recordings_expiring_soon: Recording.ready.expiring_within(7).count,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ module Mediamtx
|
|||||||
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
|
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
|
||||||
recordSegmentDuration: "60s"
|
recordSegmentDuration: "60s"
|
||||||
}
|
}
|
||||||
|
if record
|
||||||
|
retention_hours = session.match.team.entitlements.recording_retention_days * 24 + 48
|
||||||
|
body[:recordDeleteAfter] = "#{retention_hours}h"
|
||||||
|
end
|
||||||
unless session.matchlivetv_platform?
|
unless session.matchlivetv_platform?
|
||||||
body[:runOnReady] = run_on_ready_script(session)
|
body[:runOnReady] = run_on_ready_script(session)
|
||||||
body[:runOnReadyRestart] = true
|
body[:runOnReadyRestart] = true
|
||||||
|
|||||||
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
|
module Recordings
|
||||||
|
# @deprecated Use Recordings::FinalizeSession
|
||||||
class IndexSession
|
class IndexSession
|
||||||
def initialize(session)
|
def initialize(session)
|
||||||
@session = session
|
@session = session
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
team = @session.match.team
|
FinalizeSession.new(@session).call
|
||||||
return unless team.entitlements.can_access_recordings?
|
|
||||||
|
|
||||||
retention_days = team.entitlements.recording_retention_days
|
|
||||||
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
|
|
||||||
|
|
||||||
recording = Recording.find_or_initialize_by(stream_session: @session)
|
|
||||||
recording.assign_attributes(
|
|
||||||
team: team,
|
|
||||||
status: "ready",
|
|
||||||
storage_path: @session.mediamtx_path_name,
|
|
||||||
expires_at: expires_at
|
|
||||||
)
|
|
||||||
recording.save!
|
|
||||||
recording
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
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)
|
Sessions::RegiaAccess.revoke!(@session)
|
||||||
@session.end_stream!
|
@session.end_stream!
|
||||||
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
||||||
Recordings::IndexSession.new(@session).call
|
|
||||||
Mediamtx::Client.new.delete_path(@session)
|
recording = Recordings::FinalizeSession.new(@session).call
|
||||||
|
if recording
|
||||||
|
Recordings::UploadJob.perform_async(@session.id)
|
||||||
|
else
|
||||||
|
Mediamtx::Client.new.delete_path(@session)
|
||||||
|
end
|
||||||
|
|
||||||
log_event("ended")
|
log_event("ended")
|
||||||
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
|
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
|
||||||
@session
|
@session
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ module Youtube
|
|||||||
when :platform
|
when :platform
|
||||||
PlatformCredential.configured? ? PlatformCredential.new : nil
|
PlatformCredential.configured? ? PlatformCredential.new : nil
|
||||||
when :team
|
when :team
|
||||||
@team.youtube_credential
|
@team.club.youtube_credential
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ module Youtube
|
|||||||
end
|
end
|
||||||
|
|
||||||
def team_channel_available?
|
def team_channel_available?
|
||||||
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.youtube_credential.present?
|
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.club.youtube_credential.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
def effective_channel
|
def effective_channel
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ module Youtube
|
|||||||
PURPOSE = :youtube_oauth_connect
|
PURPOSE = :youtube_oauth_connect
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def for_team(team_id:, user_id:, return_app: false)
|
def for_club(club_id:, user_id:, return_app: false)
|
||||||
payload = [team_id.to_s, user_id.to_s]
|
payload = ["club", club_id.to_s, user_id.to_s]
|
||||||
payload << "app" if return_app
|
payload << "app" if return_app
|
||||||
verifier.generate(payload, expires_in: 1.hour)
|
verifier.generate(payload, expires_in: 1.hour)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# API mobile: accetta team_id ma firma OAuth per la società.
|
||||||
|
def for_team(team_id:, user_id:, return_app: false)
|
||||||
|
club_id = Team.find(team_id).club_id
|
||||||
|
for_club(club_id: club_id, user_id: user_id, return_app: return_app)
|
||||||
|
end
|
||||||
|
|
||||||
def for_platform(admin_id:)
|
def for_platform(admin_id:)
|
||||||
verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour)
|
verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour)
|
||||||
end
|
end
|
||||||
@@ -19,10 +25,21 @@ module Youtube
|
|||||||
when Array
|
when Array
|
||||||
if payload.first == "platform"
|
if payload.first == "platform"
|
||||||
{ kind: :platform, admin_id: payload[1] }
|
{ kind: :platform, admin_id: payload[1] }
|
||||||
elsif payload.size >= 2
|
elsif payload.first == "club" && payload.size >= 3
|
||||||
{
|
{
|
||||||
kind: :team,
|
kind: :club,
|
||||||
team_id: payload[0],
|
club_id: payload[1],
|
||||||
|
user_id: payload[2],
|
||||||
|
return_app: payload[3] == "app"
|
||||||
|
}
|
||||||
|
elsif payload.size >= 2
|
||||||
|
# Stato legacy (team_id): risolvi la società
|
||||||
|
team = Team.find_by(id: payload[0])
|
||||||
|
raise ArgumentError, "stato OAuth non valido" unless team
|
||||||
|
|
||||||
|
{
|
||||||
|
kind: :club,
|
||||||
|
club_id: team.club_id,
|
||||||
user_id: payload[1],
|
user_id: payload[1],
|
||||||
return_app: payload[2] == "app"
|
return_app: payload[2] == "app"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ module Youtube
|
|||||||
when "matchlivetv_light"
|
when "matchlivetv_light"
|
||||||
PlatformCredential.configured?
|
PlatformCredential.configured?
|
||||||
when "team"
|
when "team"
|
||||||
@team.youtube_credential.present? || PlatformCredential.configured?
|
@team.club.youtube_credential.present? || PlatformCredential.configured?
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
@@ -31,7 +31,7 @@ module Youtube
|
|||||||
if @mode == "matchlivetv_light"
|
if @mode == "matchlivetv_light"
|
||||||
PlatformCredential.configured?
|
PlatformCredential.configured?
|
||||||
elsif @mode == "team"
|
elsif @mode == "team"
|
||||||
@team.youtube_credential.blank? && PlatformCredential.configured?
|
@team.club.youtube_credential.blank? && PlatformCredential.configured?
|
||||||
else
|
else
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
@@ -41,12 +41,12 @@ module Youtube
|
|||||||
if uses_platform_channel?
|
if uses_platform_channel?
|
||||||
PLATFORM_LABEL
|
PLATFORM_LABEL
|
||||||
else
|
else
|
||||||
@team.youtube_credential&.channel_title
|
@team.club.youtube_credential&.channel_title
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def needs_team_oauth?
|
def needs_team_oauth?
|
||||||
@mode == "team" && @ent.premium_full? && @team.youtube_credential.blank? && !PlatformCredential.configured?
|
@mode == "team" && @ent.premium_full? && @team.club.youtube_credential.blank? && !PlatformCredential.configured?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
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) %>
|
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
|
||||||
|
|
||||||
|
<% cred = @club.youtube_credential %>
|
||||||
|
<p style="margin-top:16px">
|
||||||
|
<strong>YouTube società:</strong>
|
||||||
|
<% if cred %>
|
||||||
|
<%= cred.channel_title.presence || cred.channel_id %> (collegato)
|
||||||
|
<% elsif @teams.any? { |t| t.entitlements.premium_full? } %>
|
||||||
|
<span class="muted">Premium Full — canale società non collegato (usa Match Live TV)</span>
|
||||||
|
<% else %>
|
||||||
|
<span class="muted">—</span>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
|
||||||
<h3 style="font-size:1rem;margin-top:28px">Squadre</h3>
|
<h3 style="font-size:1rem;margin-top:28px">Squadre</h3>
|
||||||
<% if @teams.empty? %>
|
<% if @teams.empty? %>
|
||||||
<p class="muted">Nessuna squadra registrata.</p>
|
<p class="muted">Nessuna squadra registrata.</p>
|
||||||
@@ -18,32 +30,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Squadra</th>
|
<th>Squadra</th>
|
||||||
<th>Sport</th>
|
<th>Sport</th>
|
||||||
<th>YouTube</th>
|
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<% @teams.each do |team| %>
|
<% @teams.each do |team| %>
|
||||||
<% yt = Youtube::TeamStatus.new(team) %>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong><%= team.name %></strong></td>
|
<td><strong><%= team.name %></strong></td>
|
||||||
<td><%= team.sport %></td>
|
<td><%= team.sport %></td>
|
||||||
<td>
|
|
||||||
<% if yt.selectable? %>
|
|
||||||
<% if yt.uses_platform_channel? %>
|
|
||||||
<span style="color:#81c784">Match Live TV</span>
|
|
||||||
<% if team.youtube_credential.blank? && team.entitlements.premium_full? %>
|
|
||||||
<span class="muted"> (default Full)</span>
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<span style="color:#81c784">✓ <%= yt.channel_title.presence || "Canale società" %></span>
|
|
||||||
<% end %>
|
|
||||||
<% elsif team.entitlements.youtube_enabled? %>
|
|
||||||
<span class="muted">In configurazione</span>
|
|
||||||
<% else %>
|
|
||||||
—
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td><%= link_to "Partite e dettagli", admin_team_path(team) %></td>
|
<td><%= link_to "Partite e dettagli", admin_team_path(team) %></td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<% if yt.selectable? %>
|
<% if yt.selectable? %>
|
||||||
<strong><%= yt.channel_title || "—" %></strong>
|
<strong><%= yt.channel_title || "—" %></strong>
|
||||||
<% if yt.uses_platform_channel? %>
|
<% if yt.uses_platform_channel? %>
|
||||||
(canale piattaforma<%= @team.entitlements.premium_full? && @team.youtube_credential.blank? ? ", default senza OAuth società" : "" %>)
|
(canale piattaforma<%= @team.entitlements.premium_full? && @team.club.youtube_credential.blank? ? ", default senza OAuth società" : "" %>)
|
||||||
<% else %>
|
<% else %>
|
||||||
(canale società)
|
(canale società)
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -21,10 +21,10 @@
|
|||||||
non pronto
|
non pronto
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
<% if @team.entitlements.premium_full? && @team.youtube_credential.blank? %>
|
<% if @team.entitlements.premium_full? && @team.club.youtube_credential.blank? %>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Senza canale collegato, l’app usa il canale Match Live TV.
|
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>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=34">
|
<link rel="stylesheet" href="/marketing.css?v=34">
|
||||||
<link rel="stylesheet" href="/live.css?v=3">
|
<link rel="stylesheet" href="/live.css?v=4">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
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>
|
<h2>YouTube Live</h2>
|
||||||
|
|
||||||
<% if params[:youtube] == "connected" %>
|
<% if params[:youtube] == "connected" %>
|
||||||
<p class="notice" style="color:#2e7d32;margin-bottom:12px">Canale YouTube collegato con successo.</p>
|
<p class="notice" style="color:#2e7d32;margin-bottom:12px">Canale YouTube della società collegato con successo.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if entitlements.plan.youtube_mode == "matchlivetv_light" %>
|
<% if entitlements.plan.youtube_mode == "matchlivetv_light" %>
|
||||||
<p>
|
<p>
|
||||||
Con il piano <strong>Premium Light</strong> la diretta va sul canale ufficiale
|
Con il piano <strong>Premium Light</strong> le dirette YouTube di tutte le squadre vanno sul canale ufficiale
|
||||||
<strong>Match Live TV</strong>. Non serve collegare un canale della società.
|
<strong>Match Live TV</strong>. Non serve collegare un canale della società.
|
||||||
</p>
|
</p>
|
||||||
<% if yt.selectable? %>
|
<% if yt&.selectable? %>
|
||||||
<p class="muted">Canale pronto · seleziona «YouTube Live» nell’app mobile.</p>
|
<p class="muted">Canale pronto · seleziona «YouTube Live» nell’app mobile.</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="muted">Canale Match Live TV in configurazione lato server. Contatta il supporto se YouTube non compare in app.</p>
|
<p class="muted">Canale Match Live TV in configurazione lato server. Contatta il supporto se YouTube non compare in app.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% elsif entitlements.premium_full? %>
|
<% elsif entitlements.premium_full? %>
|
||||||
<% cred = team.youtube_credential %>
|
|
||||||
<% if cred %>
|
<% if cred %>
|
||||||
<p>
|
<p>
|
||||||
Canale collegato:
|
Canale collegato per <strong>tutta la società</strong>:
|
||||||
<strong><%= cred.channel_title.presence || cred.channel_id || "YouTube" %></strong>
|
<strong><%= cred.channel_title.presence || cred.channel_id || "YouTube" %></strong>
|
||||||
</p>
|
</p>
|
||||||
<%= button_to "Scollega canale", public_team_youtube_disconnect_path(team),
|
<p class="muted">Tutte le squadre possono trasmettere su questo canale quando scelgono il canale società in app.</p>
|
||||||
|
<%= button_to "Scollega canale", public_club_youtube_disconnect_path(club),
|
||||||
method: :delete,
|
method: :delete,
|
||||||
class: "btn btn-secondary",
|
class: "btn btn-secondary",
|
||||||
form: { data: { turbo_confirm: "Scollegare il canale YouTube? Le dirette useranno il canale Match Live TV finché non ricolleghi il tuo." } } %>
|
form: { data: { turbo_confirm: "Scollegare il canale YouTube? Le dirette useranno il canale Match Live TV finché non ricolleghi il tuo." } } %>
|
||||||
@@ -33,13 +34,13 @@
|
|||||||
<p>
|
<p>
|
||||||
Senza canale collegato, le dirette YouTube dall’app vanno sul canale ufficiale
|
Senza canale collegato, le dirette YouTube dall’app vanno sul canale ufficiale
|
||||||
<strong>Match Live TV</strong>.
|
<strong>Match Live TV</strong>.
|
||||||
Puoi collegare il canale della società quando vuoi per trasmettere sul tuo profilo.
|
Collega qui il canale YouTube della società (vale per tutte le squadre).
|
||||||
</p>
|
</p>
|
||||||
<% if yt.selectable? %>
|
<% if yt&.selectable? %>
|
||||||
<p class="muted">YouTube pronto in app (canale Match Live TV).</p>
|
<p class="muted">YouTube pronto in app (canale Match Live TV finché non colleghi il tuo).</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if ENV["YOUTUBE_CLIENT_ID"].present? %>
|
<% if ENV["YOUTUBE_CLIENT_ID"].present? %>
|
||||||
<%= link_to "Collega il tuo canale YouTube", public_team_youtube_connect_path(team), class: "btn btn-primary" %>
|
<%= link_to "Collega il canale YouTube della società", public_club_youtube_connect_path(club), class: "btn btn-primary" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="muted">OAuth YouTube non ancora configurato sul server.</p>
|
<p class="muted">OAuth YouTube non ancora configurato sul server.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -22,8 +22,38 @@
|
|||||||
<%= link_to "Abbonamento", public_club_billing_path(@club), class: "btn btn-primary" %>
|
<%= link_to "Abbonamento", public_club_billing_path(@club), class: "btn btn-primary" %>
|
||||||
<%= link_to "Nuova squadra", public_new_club_team_path(@club), class: "btn btn-secondary" %>
|
<%= link_to "Nuova squadra", public_new_club_team_path(@club), class: "btn btn-secondary" %>
|
||||||
<%= link_to "Dirette live", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
|
<%= link_to "Dirette live", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
|
||||||
|
<% if @entitlements&.can_access_recordings? %>
|
||||||
|
<%= link_to "Archivio Replay", public_club_recordings_path(@club), class: "btn btn-secondary" %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= render "public/clubs/youtube",
|
||||||
|
club: @club,
|
||||||
|
entitlements: @entitlements,
|
||||||
|
entitlements_team: @entitlements_team %>
|
||||||
|
|
||||||
|
<% if @replay_stats %>
|
||||||
|
<h2>Replay</h2>
|
||||||
|
<div class="card" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:16px;padding:16px;margin-bottom:24px">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:1.6rem;font-weight:700"><%= @replay_stats[:available_count] %></div>
|
||||||
|
<div class="muted">disponibili</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:1.6rem;font-weight:700"><%= @replay_stats[:expiring_soon_count] %></div>
|
||||||
|
<div class="muted">in scadenza (7 gg)</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:1.6rem;font-weight:700"><%= number_to_human_size(@replay_stats[:total_bytes]) %></div>
|
||||||
|
<div class="muted">spazio occupato</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:1.6rem;font-weight:700"><%= @replay_stats[:total_views] %></div>
|
||||||
|
<div class="muted">visualizzazioni</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<h2>Squadre</h2>
|
<h2>Squadre</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<% if @teams.any? %>
|
<% if @teams.any? %>
|
||||||
|
|||||||
@@ -12,11 +12,17 @@
|
|||||||
<p class="results-hint">
|
<p class="results-hint">
|
||||||
Solo le squadre di questa società: dirette in corso e partite programmate da app o sito.
|
Solo le squadre di questa società: dirette in corso e partite programmate da app o sito.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="live-actions" style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">
|
||||||
|
<%= link_to "Replay di #{@club.name}", public_replay_index_path(club_id: @club.id), class: "btn btn-secondary" %>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<h1>Dirette e partite in programma</h1>
|
<h1>Dirette e partite in programma</h1>
|
||||||
<p class="results-hint">
|
<p class="results-hint">
|
||||||
Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate.
|
Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate.
|
||||||
</p>
|
</p>
|
||||||
|
<div class="live-actions" style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px">
|
||||||
|
<%= link_to "Live passate — archivio replay", public_replay_index_path, class: "btn btn-secondary" %>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @club && @can_schedule_match %>
|
<% if @club && @can_schedule_match %>
|
||||||
|
|||||||
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 :title, "Replay — #{@recording.title_or_default}" %>
|
||||||
<% content_for :meta_description, "Replay partita #{@match.team.name} vs #{@match.opponent_name} su Match Live TV." %>
|
<% content_for :meta_description, "Replay #{@recording.title_or_default} su Match Live TV." %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, @recording.publicly_listed? ? "index, follow" : "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="wrap">
|
<div class="wrap replay-show">
|
||||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
<%= link_to "← Live passate", public_replay_index_path, class: "back-link" %>
|
||||||
|
|
||||||
<h1>Replay</h1>
|
<header class="replay-show__header">
|
||||||
<p style="color:#aaa"><%= @match.team.name %> vs <%= @match.opponent_name %></p>
|
<h1><%= @recording.title_or_default %></h1>
|
||||||
|
<p class="replay-show__meta">
|
||||||
|
<%= @match.team.club.name %> · <%= l_local(@recording.recorded_at_or_fallback) %>
|
||||||
|
· <%= @recording.views_label %>
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<% if @recording %>
|
<% if @recording.status == "processing" %>
|
||||||
<div class="card" style="margin:16px 0">
|
<div class="card replay-show__status">
|
||||||
<% if @session.hls_playback_url.present? %>
|
<p>Registrazione in elaborazione. Riceverai un’email quando sarà pronta.</p>
|
||||||
<video id="replay-player" class="replay-video" controls playsinline></video>
|
</div>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script>
|
<% elsif @recording.ready? %>
|
||||||
<script>
|
<div class="replay-show__player card">
|
||||||
(function() {
|
<video id="replay-player" class="replay-video" controls playsinline preload="metadata"
|
||||||
var src = "<%= j @session.hls_playback_url %>";
|
<% if @recording.thumbnail_url %> poster="<%= @recording.thumbnail_url %>"<% end %>>
|
||||||
var video = document.getElementById("replay-player");
|
<source src="<%= public_replay_stream_path(@session) %>" type="video/mp4">
|
||||||
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
</video>
|
||||||
video.src = src;
|
</div>
|
||||||
} else if (typeof Hls !== "undefined" && Hls.isSupported()) {
|
|
||||||
var hls = new Hls();
|
<div class="replay-show__details card">
|
||||||
hls.loadSource(src);
|
<p class="replay-show__details-line">
|
||||||
hls.attachMedia(video);
|
<span>Durata <%= @recording.duration_label %></span>
|
||||||
} else {
|
<% if @recording.expires_at %>
|
||||||
video.outerHTML = "<p>Il replay non è disponibile su questo browser.</p>";
|
<span>· Disponibile fino al <%= l_local(@recording.expires_at) %></span>
|
||||||
}
|
<% end %>
|
||||||
})();
|
<span>· <%= @recording.publicly_listed? ? "Pubblico" : "Privato (link)" %></span>
|
||||||
</script>
|
<% if @recording.byte_size.to_i.positive? %>
|
||||||
<% else %>
|
<span>· <%= @recording.byte_size_label %></span>
|
||||||
<p>Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.</p>
|
<% end %>
|
||||||
<% end %>
|
</p>
|
||||||
<% if @recording.expires_at %>
|
<% ent = @recording.team.entitlements %>
|
||||||
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l_local(@recording.expires_at) %></p>
|
<div class="replay-show__actions">
|
||||||
<% end %>
|
<% if ent.phone_download_enabled? && (logged_in? || @recording.unlisted?) %>
|
||||||
|
<%= link_to "Scarica MP4", public_replay_download_path(@session), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
<% if @recording.youtube_watch_url %>
|
||||||
|
<%= link_to "Apri su YouTube", @recording.youtube_watch_url, class: "btn btn-secondary", target: "_blank", rel: "noopener" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% elsif @recording.status == "failed" %>
|
||||||
|
<div class="card replay-show__status">
|
||||||
|
<p>Replay non disponibile: <%= @recording.error_message.presence || "errore di elaborazione" %>.</p>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="card replay-show__status">
|
||||||
|
<p>Replay non più disponibile.</p>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,20 +81,37 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if entitlements.can_access_recordings? && recordings.any? %>
|
<% if entitlements.can_access_recordings? %>
|
||||||
<h2>Archivio gare</h2>
|
<h2>Archivio replay</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<table class="data">
|
<% if recordings.any? %>
|
||||||
<thead><tr><th>Partita</th><th>Replay</th></tr></thead>
|
<table class="data">
|
||||||
<tbody>
|
<thead><tr><th>Partita</th><th>Data</th><th>Durata</th><th>Scadenza</th><th>Stato</th><th></th></tr></thead>
|
||||||
<% recordings.each do |rec| %>
|
<tbody>
|
||||||
<tr>
|
<% recordings.each do |rec| %>
|
||||||
<td><%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %></td>
|
<tr>
|
||||||
<td><%= link_to "Guarda", rec.replay_url %></td>
|
<td><%= rec.title_or_default %></td>
|
||||||
</tr>
|
<td><%= l_local(rec.recorded_at_or_fallback) %></td>
|
||||||
<% end %>
|
<td><%= rec.duration_label %></td>
|
||||||
</tbody>
|
<td><%= rec.expires_at ? l_local(rec.expires_at) : "—" %></td>
|
||||||
</table>
|
<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>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -92,7 +92,12 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= render "public/teams/youtube", team: @team, entitlements: @entitlements %>
|
<% if @can_manage && @entitlements.premium_full? && @entitlements.youtube_enabled? %>
|
||||||
|
<p class="muted" style="margin:16px 0">
|
||||||
|
Il canale YouTube della società si configura dalla pagina
|
||||||
|
<%= link_to @club.name, public_club_path(@club) %> (sezione YouTube Live).
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= render "public/teams/streaming_staff", team: @team, club: @club, can_manage: @can_manage,
|
<%= render "public/teams/streaming_staff", team: @team, club: @club, can_manage: @can_manage,
|
||||||
entitlements: @entitlements, owner_membership: @owner_membership,
|
entitlements: @entitlements, owner_membership: @owner_membership,
|
||||||
|
|||||||
@@ -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 "Privacy", public_privacy_path %> ·
|
||||||
<%= link_to "Termini", public_termini_path %>
|
<%= link_to "Termini", public_termini_path %>
|
||||||
</div>
|
</div>
|
||||||
<div class="site-footer__stripe">
|
<div class="site-footer__legal">
|
||||||
<%= render "shared/stripe_secure_payment", compact: true %>
|
<p>© 2026 Emiliano Frascaro – P. IVA 14230270960</p>
|
||||||
|
<p>I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -96,5 +96,49 @@ module MatchLiveTv
|
|||||||
def password_reset_expiry_hours
|
def password_reset_expiry_hours
|
||||||
ENV.fetch("PASSWORD_RESET_EXPIRY_HOURS", "2").to_i
|
ENV.fetch("PASSWORD_RESET_EXPIRY_HOURS", "2").to_i
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def replay_storage_configured?
|
||||||
|
replay_storage_endpoint.present? || replay_storage_local?
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_storage_endpoint
|
||||||
|
ENV["REPLAY_STORAGE_ENDPOINT"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_storage_bucket
|
||||||
|
ENV.fetch("REPLAY_STORAGE_BUCKET", "matchlivetv-replays")
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_storage_region
|
||||||
|
ENV.fetch("REPLAY_STORAGE_REGION", "garage")
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_storage_access_key_id
|
||||||
|
ENV["REPLAY_STORAGE_ACCESS_KEY_ID"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_storage_secret_access_key
|
||||||
|
ENV["REPLAY_STORAGE_SECRET_ACCESS_KEY"].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_storage_force_path_style?
|
||||||
|
ENV.fetch("REPLAY_STORAGE_FORCE_PATH_STYLE", "true") == "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_storage_local?
|
||||||
|
replay_storage_endpoint.blank?
|
||||||
|
end
|
||||||
|
|
||||||
|
def recordings_local_path
|
||||||
|
ENV.fetch("RECORDINGS_PATH", "/recordings")
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_presigned_url_ttl_seconds
|
||||||
|
ENV.fetch("REPLAY_PRESIGNED_URL_TTL_SECONDS", "7200").to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def replay_download_url_ttl_seconds
|
||||||
|
ENV.fetch("REPLAY_DOWNLOAD_URL_TTL_SECONDS", "900").to_i
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ Rails.application.routes.draw do
|
|||||||
get "youtube/authorize", to: "youtube#authorize"
|
get "youtube/authorize", to: "youtube#authorize"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :recordings, only: %i[update destroy], controller: "recordings" do
|
||||||
|
member do
|
||||||
|
get :download
|
||||||
|
post :publish_youtube
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
resources :matches, only: %i[show update destroy] do
|
resources :matches, only: %i[show update destroy] do
|
||||||
resources :sessions, only: %i[create], controller: "stream_sessions"
|
resources :sessions, only: %i[create], controller: "stream_sessions"
|
||||||
end
|
end
|
||||||
@@ -87,7 +94,11 @@ Rails.application.routes.draw do
|
|||||||
get "live", to: "public/live#index", as: :public_live_index
|
get "live", to: "public/live#index", as: :public_live_index
|
||||||
get "live/:id", to: "public/live#show", as: :public_live
|
get "live/:id", to: "public/live#show", as: :public_live
|
||||||
get "live/:id/status.json", to: "public/live#status", as: :public_live_status
|
get "live/:id/status.json", to: "public/live#status", as: :public_live_status
|
||||||
|
get "replay", to: "public/replay#index", as: :public_replay_index
|
||||||
get "replay/:id", to: "public/replay#show", as: :public_replay
|
get "replay/:id", to: "public/replay#show", as: :public_replay
|
||||||
|
get "replay/:id/stream", to: "public/replay#stream", as: :public_replay_stream
|
||||||
|
get "replay/:id/thumbnail", to: "public/replay#thumbnail", as: :public_replay_thumbnail
|
||||||
|
get "replay/:id/download", to: "public/replay#download", as: :public_replay_download
|
||||||
|
|
||||||
get "regia/:token", to: "public/regia#show", as: :public_regia
|
get "regia/:token", to: "public/regia#show", as: :public_regia
|
||||||
get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status
|
get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status
|
||||||
@@ -121,12 +132,18 @@ Rails.application.routes.draw do
|
|||||||
get "clubs/:id/edit", to: "clubs#edit", as: :edit_club
|
get "clubs/:id/edit", to: "clubs#edit", as: :edit_club
|
||||||
patch "clubs/:id", to: "clubs#update"
|
patch "clubs/:id", to: "clubs#update"
|
||||||
get "clubs/:id/billing", to: "clubs#billing", as: :club_billing
|
get "clubs/:id/billing", to: "clubs#billing", as: :club_billing
|
||||||
|
get "clubs/:club_id/replays", to: "club_recordings#index", as: :club_recordings
|
||||||
|
patch "clubs/:club_id/replays/:id", to: "club_recordings#update", as: :club_recording
|
||||||
|
delete "clubs/:club_id/replays/:id", to: "club_recordings#destroy"
|
||||||
|
post "clubs/:club_id/replays/:id/publish_youtube", to: "club_recordings#publish_youtube", as: :club_recording_publish_youtube
|
||||||
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile
|
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile
|
||||||
patch "clubs/:id/billing/profile", to: "club_billing#update_profile"
|
patch "clubs/:id/billing/profile", to: "club_billing#update_profile"
|
||||||
post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription
|
post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription
|
||||||
get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice
|
get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice
|
||||||
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
|
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
|
||||||
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
|
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
|
||||||
|
get "clubs/:id/youtube/connect", to: "clubs#youtube_connect", as: :club_youtube_connect
|
||||||
|
delete "clubs/:id/youtube/disconnect", to: "clubs#youtube_disconnect", as: :club_youtube_disconnect
|
||||||
get "clubs/:club_id/matches/new", to: "club_matches#new", as: :new_club_match
|
get "clubs/:club_id/matches/new", to: "club_matches#new", as: :new_club_match
|
||||||
post "clubs/:club_id/matches", to: "club_matches#create", as: :club_matches
|
post "clubs/:club_id/matches", to: "club_matches#create", as: :club_matches
|
||||||
get "teams/new", to: redirect("/clubs/new")
|
get "teams/new", to: redirect("/clubs/new")
|
||||||
|
|||||||
@@ -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.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_06_03_121000) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -176,9 +176,32 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
|
|||||||
t.datetime "expires_at"
|
t.datetime "expires_at"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "title"
|
||||||
|
t.string "privacy_status", default: "unlisted", null: false
|
||||||
|
t.bigint "byte_size"
|
||||||
|
t.integer "duration_secs"
|
||||||
|
t.string "storage_key"
|
||||||
|
t.string "storage_bucket"
|
||||||
|
t.string "storage_backend", default: "local", null: false
|
||||||
|
t.datetime "recorded_at"
|
||||||
|
t.datetime "deleted_at"
|
||||||
|
t.text "error_message"
|
||||||
|
t.jsonb "metadata", default: {}, null: false
|
||||||
|
t.string "thumbnail_storage_key"
|
||||||
|
t.integer "view_count", default: 0, null: false
|
||||||
|
t.datetime "ready_notified_at"
|
||||||
|
t.datetime "expiry_warning_sent_at"
|
||||||
|
t.string "youtube_video_id"
|
||||||
|
t.datetime "youtube_published_at"
|
||||||
|
t.index ["deleted_at"], name: "index_recordings_on_deleted_at"
|
||||||
t.index ["expires_at"], name: "index_recordings_on_expires_at"
|
t.index ["expires_at"], name: "index_recordings_on_expires_at"
|
||||||
|
t.index ["privacy_status"], name: "index_recordings_on_privacy_status"
|
||||||
|
t.index ["storage_key"], name: "index_recordings_on_storage_key", unique: true, where: "(storage_key IS NOT NULL)"
|
||||||
t.index ["stream_session_id"], name: "index_recordings_on_stream_session_id", unique: true
|
t.index ["stream_session_id"], name: "index_recordings_on_stream_session_id", unique: true
|
||||||
|
t.index ["team_id", "status"], name: "index_recordings_on_team_id_and_status"
|
||||||
t.index ["team_id"], name: "index_recordings_on_team_id"
|
t.index ["team_id"], name: "index_recordings_on_team_id"
|
||||||
|
t.index ["view_count"], name: "index_recordings_on_view_count"
|
||||||
|
t.index ["youtube_video_id"], name: "index_recordings_on_youtube_video_id", where: "(youtube_video_id IS NOT NULL)"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "score_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "score_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
@@ -334,7 +357,6 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
|
|||||||
end
|
end
|
||||||
|
|
||||||
create_table "youtube_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "youtube_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "team_id", null: false
|
|
||||||
t.text "access_token_encrypted"
|
t.text "access_token_encrypted"
|
||||||
t.text "refresh_token_encrypted"
|
t.text "refresh_token_encrypted"
|
||||||
t.datetime "expires_at"
|
t.datetime "expires_at"
|
||||||
@@ -342,7 +364,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
|
|||||||
t.string "channel_title"
|
t.string "channel_title"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["team_id"], name: "index_youtube_credentials_on_team_id"
|
t.uuid "club_id", null: false
|
||||||
|
t.index ["club_id"], name: "index_youtube_credentials_on_club_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||||
@@ -369,5 +392,5 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_03_120000) do
|
|||||||
add_foreign_key "teams", "clubs"
|
add_foreign_key "teams", "clubs"
|
||||||
add_foreign_key "user_teams", "teams"
|
add_foreign_key "user_teams", "teams"
|
||||||
add_foreign_key "user_teams", "users"
|
add_foreign_key "user_teams", "users"
|
||||||
add_foreign_key "youtube_credentials", "teams"
|
add_foreign_key "youtube_credentials", "clubs"
|
||||||
end
|
end
|
||||||
|
|||||||
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;
|
border-radius: 12px;
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.live-main .replay-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main a.replay-card {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #eee;
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid #2a2a36;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main a.replay-card:hover {
|
||||||
|
border-color: #e53935;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__media {
|
||||||
|
position: relative;
|
||||||
|
background: #0a0a0e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__thumb {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #111;
|
||||||
|
display: block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__thumb--placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 140px;
|
||||||
|
background: linear-gradient(145deg, #1a1a24 0%, #0d0d12 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__placeholder-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__play {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(229, 57, 53, 0.92);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 52px;
|
||||||
|
text-align: center;
|
||||||
|
padding-left: 4px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__duration {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__body {
|
||||||
|
padding: 12px 14px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__title {
|
||||||
|
display: block;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-card__meta--sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-show__header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-show__header h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-show__meta {
|
||||||
|
margin: 0;
|
||||||
|
color: #999;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-show__player {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-show__player .replay-video {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-show__details {
|
||||||
|
padding: 16px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-show__details-line {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-show__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-filter-form {
|
||||||
|
margin: 12px 0 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-main .replay-filter-form .input {
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #333;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #14141c;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1014,7 +1014,9 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.stripe-secure--compact i { font-size: 0.95rem; color: #888; }
|
.stripe-secure--compact i { font-size: 0.95rem; color: #888; }
|
||||||
.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; }
|
.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; }
|
||||||
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; }
|
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; }
|
||||||
.site-footer__stripe { flex: 1 1 100%; margin-top: 4px; }
|
.site-footer__legal { flex: 1 1 100%; margin-top: 4px; }
|
||||||
|
.site-footer__legal p { margin: 0 0 6px; line-height: 1.45; }
|
||||||
|
.site-footer__legal p:last-child { margin-bottom: 0; }
|
||||||
.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; }
|
.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; }
|
||||||
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||||
.compare-table th { color: #aaa; font-weight: 600; }
|
.compare-table th { color: #aaa; font-weight: 600; }
|
||||||
|
|||||||
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 collegato | Canale **della società** |
|
||||||
| Premium Full, canale non collegato | **Match Live TV** (default, come Light) |
|
| Premium Full, canale non collegato | **Match Live TV** (default, come Light) |
|
||||||
|
|
||||||
Il collegamento del canale società è **opzionale** su Full: si fa dal sito (dettaglio squadra) o in futuro da app; finché non c’è OAuth, 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
|
## 1. Invito
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ API: `GET /api/v1/invitations/:token`, `POST /api/v1/invitations/:token/accept`
|
|||||||
|
|
||||||
Solo se la società vuole il **proprio** canale invece di Match Live TV:
|
Solo se la società vuole il **proprio** canale invece di Match Live TV:
|
||||||
|
|
||||||
1. Sul sito: dettaglio squadra → **Collega il tuo canale YouTube**
|
1. Sul sito: pagina società (`/clubs/:id`) → **Collega il canale YouTube della società**
|
||||||
2. Oppure (se abilitato) OAuth da app al tocco YouTube quando non c’è ancora canale piattaforma
|
2. Oppure (se abilitato) OAuth da app al tocco YouTube quando non c’è ancora canale piattaforma
|
||||||
|
|
||||||
## 3. Scegli la partita
|
## 3. Scegli la partita
|
||||||
|
|||||||
105
docs/REPLAY_MODULE.md
Normal file
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
|
## 5. Verifica
|
||||||
|
|
||||||
- Premium Full: dopo collegamento, `GET /api/v1/teams/:id` → `youtube_connected: true`, `youtube_selectable: true`
|
- Premium Full: collegamento OAuth dalla pagina società (`/clubs/:id`); credenziali uniche per club. Dopo collegamento, `GET /api/v1/teams/:id` → `youtube_connected: true`, `youtube_selectable: true`
|
||||||
- Premium Light: con `YOUTUBE_PLATFORM_REFRESH_TOKEN` → `youtube_selectable: true` anche senza OAuth squadra
|
- Premium Light: con `YOUTUBE_PLATFORM_REFRESH_TOKEN` → `youtube_selectable: true` anche senza OAuth squadra
|
||||||
- Avvia diretta YouTube da app → in admin session compare broadcast ID → link YouTube Studio
|
- Avvia diretta YouTube da app → in admin session compare broadcast ID → link YouTube Studio
|
||||||
|
|
||||||
|
|||||||
@@ -26,3 +26,24 @@ STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID=
|
|||||||
# Legacy (equivale a YEARLY se le righe sopra sono vuote)
|
# Legacy (equivale a YEARLY se le righe sopra sono vuote)
|
||||||
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
||||||
STRIPE_PREMIUM_FULL_PRICE_ID=
|
STRIPE_PREMIUM_FULL_PRICE_ID=
|
||||||
|
|
||||||
|
# --- Replay storage (dopo ogni diretta Premium) ---
|
||||||
|
# Dev locale con Docker: Garage parte con `docker compose up` (servizio garage).
|
||||||
|
# Bootstrap una tantum: bash infra/scripts/setup_garage_replays.sh → aggiorna .env con le chiavi.
|
||||||
|
# MODALITÀ A — solo filesystem: lascia REPLAY_STORAGE_ENDPOINT vuoto (volume /recordings/replays/).
|
||||||
|
# MODALITÀ B — Garage/S3: compila tutte le righe sotto (endpoint rete Docker: http://garage:3900).
|
||||||
|
REPLAY_STORAGE_ENDPOINT=http://garage:3900
|
||||||
|
REPLAY_STORAGE_BUCKET=matchlivetv-replays
|
||||||
|
REPLAY_STORAGE_REGION=garage
|
||||||
|
# Credenziali S3: NON le inventi a mano. Le genera Garage con lo script di bootstrap:
|
||||||
|
# cd infra && bash scripts/setup_garage_replays.sh
|
||||||
|
# Lo script crea la chiave "replay-uploader" e scrive Key ID + Secret in infra/.env (non in questo file).
|
||||||
|
# ACCESS_KEY_ID = "Key ID" mostrato da Garage (es. GK6619e25d53883a7bc3f75755)
|
||||||
|
# SECRET_ACCESS_KEY = "Secret key" mostrata UNA SOLA VOLTA alla creazione della chiave
|
||||||
|
REPLAY_STORAGE_ACCESS_KEY_ID=
|
||||||
|
REPLAY_STORAGE_SECRET_ACCESS_KEY=
|
||||||
|
# true = obbligatorio per Garage/MinIO (path-style). Non cambiare se usi Garage.
|
||||||
|
REPLAY_STORAGE_FORCE_PATH_STYLE=true
|
||||||
|
# Opzionale: durata link firmati per play/download (secondi)
|
||||||
|
# REPLAY_PRESIGNED_URL_TTL_SECONDS=7200
|
||||||
|
# REPLAY_DOWNLOAD_URL_TTL_SECONDS=900
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ services:
|
|||||||
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
|
||||||
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
|
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
|
||||||
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
|
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
|
||||||
|
RECORDINGS_PATH: /recordings
|
||||||
|
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-}
|
||||||
|
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
|
||||||
|
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
|
||||||
|
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
||||||
|
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
||||||
|
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000" # Solo LAN — NPM proxy HTTPS
|
- "3000:3000" # Solo LAN — NPM proxy HTTPS
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -133,9 +140,18 @@ services:
|
|||||||
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
YOUTUBE_PLATFORM_REFRESH_TOKEN: ${YOUTUBE_PLATFORM_REFRESH_TOKEN:-}
|
||||||
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
HLS_PUBLIC_URL: ${HLS_PUBLIC_URL:-http://localhost:8888}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000}
|
||||||
|
RECORDINGS_PATH: /recordings
|
||||||
|
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-}
|
||||||
|
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
|
||||||
|
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
|
||||||
|
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
||||||
|
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
||||||
|
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
||||||
depends_on:
|
depends_on:
|
||||||
rails:
|
rails:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- recordings:/recordings
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -81,6 +81,13 @@ services:
|
|||||||
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
|
STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID: ${STRIPE_PREMIUM_FULL_YEARLY_PRICE_ID:-${STRIPE_PREMIUM_FULL_PRICE_ID:-}}
|
||||||
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
|
STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-}
|
||||||
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
|
STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-}
|
||||||
|
RECORDINGS_PATH: /recordings
|
||||||
|
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
|
||||||
|
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
|
||||||
|
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
|
||||||
|
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
||||||
|
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
||||||
|
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -90,6 +97,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
mediamtx:
|
mediamtx:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
garage:
|
||||||
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend:/app
|
- ../backend:/app
|
||||||
- recordings:/recordings
|
- recordings:/recordings
|
||||||
@@ -119,13 +128,33 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret}
|
JWT_SECRET: ${JWT_SECRET:-matchlivetv_jwt_dev_secret}
|
||||||
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
|
YOUTUBE_CLIENT_ID: ${YOUTUBE_CLIENT_ID:-}
|
||||||
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
|
YOUTUBE_CLIENT_SECRET: ${YOUTUBE_CLIENT_SECRET:-}
|
||||||
|
RECORDINGS_PATH: /recordings
|
||||||
|
REPLAY_STORAGE_ENDPOINT: ${REPLAY_STORAGE_ENDPOINT:-http://garage:3900}
|
||||||
|
REPLAY_STORAGE_BUCKET: ${REPLAY_STORAGE_BUCKET:-matchlivetv-replays}
|
||||||
|
REPLAY_STORAGE_REGION: ${REPLAY_STORAGE_REGION:-garage}
|
||||||
|
REPLAY_STORAGE_ACCESS_KEY_ID: ${REPLAY_STORAGE_ACCESS_KEY_ID:-}
|
||||||
|
REPLAY_STORAGE_SECRET_ACCESS_KEY: ${REPLAY_STORAGE_SECRET_ACCESS_KEY:-}
|
||||||
|
REPLAY_STORAGE_FORCE_PATH_STYLE: ${REPLAY_STORAGE_FORCE_PATH_STYLE:-true}
|
||||||
depends_on:
|
depends_on:
|
||||||
rails:
|
rails:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
garage:
|
||||||
|
condition: service_started
|
||||||
volumes:
|
volumes:
|
||||||
- ../backend:/app
|
- ../backend:/app
|
||||||
|
- recordings:/recordings
|
||||||
- bundle_cache:/usr/local/bundle
|
- bundle_cache:/usr/local/bundle
|
||||||
|
|
||||||
|
garage:
|
||||||
|
image: dxflrs/garage:v1.0.1
|
||||||
|
ports:
|
||||||
|
- "3900:3900"
|
||||||
|
volumes:
|
||||||
|
- garage_meta:/var/lib/garage/meta
|
||||||
|
- garage_data:/var/lib/garage/data
|
||||||
|
- ./garage/garage.toml:/etc/garage.toml:ro
|
||||||
|
command: ["/garage", "-c", "/etc/garage.toml", "server"]
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
ports:
|
ports:
|
||||||
@@ -140,3 +169,5 @@ volumes:
|
|||||||
redis_data:
|
redis_data:
|
||||||
recordings:
|
recordings:
|
||||||
bundle_cache:
|
bundle_cache:
|
||||||
|
garage_meta:
|
||||||
|
garage_data:
|
||||||
|
|||||||
26
infra/garage/garage.toml
Normal file
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 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../app/theme.dart';
|
import '../../app/theme.dart';
|
||||||
|
import '../../shared/api_client.dart';
|
||||||
|
import '../../shared/models/recording_item.dart';
|
||||||
import '../../shared/premium_dialog.dart';
|
import '../../shared/premium_dialog.dart';
|
||||||
import '../matches/team_providers.dart';
|
import '../matches/team_providers.dart';
|
||||||
|
|
||||||
@@ -18,7 +20,7 @@ class ArchiveScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: const Text('Archivio gare'),
|
title: const Text('Replay'),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => context.go('/matches'),
|
onPressed: () => context.go('/matches'),
|
||||||
@@ -41,15 +43,21 @@ class ArchiveScreen extends ConsumerWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Archivio replay con Premium Light o Full',
|
'Replay disponibili con Premium Light o Full',
|
||||||
style: theme.textTheme.titleMedium,
|
style: theme.textTheme.titleMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Light: 30 giorni · Full: 90 giorni',
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white70),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => showPremiumRequiredDialog(
|
onPressed: () => showPremiumRequiredDialog(
|
||||||
context,
|
context,
|
||||||
message: 'Salva e rivedi le gare per 90 giorni con il piano Premium.',
|
message: 'Salva e rivedi le gare con il piano Premium.',
|
||||||
billingUrl: team.billingUrl,
|
billingUrl: team.billingUrl,
|
||||||
),
|
),
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
@@ -71,7 +79,15 @@ class ArchiveScreen extends ConsumerWidget {
|
|||||||
error: (e, _) => Center(child: Text('$e')),
|
error: (e, _) => Center(child: Text('$e')),
|
||||||
data: (recs) {
|
data: (recs) {
|
||||||
if (recs.isEmpty) {
|
if (recs.isEmpty) {
|
||||||
return const Center(child: Text('Nessuna gara in archivio'));
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'Nessun replay ancora.\nAl termine delle dirette Premium le registrazioni compaiono qui.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT');
|
final df = DateFormat('d MMM yyyy · HH:mm', 'it_IT');
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
@@ -79,27 +95,68 @@ class ArchiveScreen extends ConsumerWidget {
|
|||||||
itemCount: recs.length,
|
itemCount: recs.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final r = recs[i];
|
final r = recs[i];
|
||||||
|
final when = r.recordedAt ?? r.endedAt;
|
||||||
|
final subtitle = [
|
||||||
|
if (when != null) df.format(when.toLocal()),
|
||||||
|
if (r.durationLabel != null) r.durationLabel,
|
||||||
|
if (r.viewsLabel != null) r.viewsLabel,
|
||||||
|
r.statusLabel,
|
||||||
|
if (r.expiresAt != null)
|
||||||
|
'Scade ${DateFormat('d MMM', 'it_IT').format(r.expiresAt!.toLocal())}',
|
||||||
|
].whereType<String>().join(' · ');
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: AppTheme.surface,
|
color: AppTheme.surface,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: Text('${r.teamName} vs ${r.opponentName}'),
|
leading: r.thumbnailUrl != null
|
||||||
subtitle: Text(
|
? ClipRRect(
|
||||||
r.endedAt != null ? df.format(r.endedAt!.toLocal()) : '—',
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
child: Image.network(
|
||||||
trailing: const Icon(Icons.play_circle_outline),
|
r.thumbnailUrl!,
|
||||||
onTap: () async {
|
width: 72,
|
||||||
final url = r.replayUrl;
|
height: 40,
|
||||||
if (url == null) return;
|
fit: BoxFit.cover,
|
||||||
final uri = Uri.parse(url);
|
errorBuilder: (context, error, stackTrace) =>
|
||||||
if (await canLaunchUrl(uri)) {
|
const Icon(Icons.movie),
|
||||||
await launchUrl(
|
),
|
||||||
uri,
|
)
|
||||||
mode: team.canDownloadOnPhone
|
: const Icon(Icons.movie_outlined, size: 40),
|
||||||
? LaunchMode.externalApplication
|
title: Text(r.title),
|
||||||
: LaunchMode.inAppBrowserView,
|
subtitle: Text(subtitle),
|
||||||
);
|
isThreeLine: true,
|
||||||
}
|
trailing: r.isProcessing
|
||||||
},
|
? const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: PopupMenuButton<String>(
|
||||||
|
onSelected: (action) => _onMenuAction(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
action,
|
||||||
|
r,
|
||||||
|
team.canDownloadOnPhone,
|
||||||
|
),
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
if (r.isReady)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'play',
|
||||||
|
child: Text('Guarda replay'),
|
||||||
|
),
|
||||||
|
if (r.isReady && r.downloadEnabled && team.canDownloadOnPhone)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'download',
|
||||||
|
child: Text('Scarica MP4'),
|
||||||
|
),
|
||||||
|
if (r.youtubeWatchUrl != null)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'youtube',
|
||||||
|
child: Text('Apri su YouTube'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: r.isReady ? () => _openReplay(r.replayUrl) : null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -110,4 +167,50 @@ class ArchiveScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openReplay(String? url) async {
|
||||||
|
if (url == null) return;
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.inAppBrowserView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMenuAction(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String action,
|
||||||
|
RecordingItem r,
|
||||||
|
bool canDownload,
|
||||||
|
) async {
|
||||||
|
switch (action) {
|
||||||
|
case 'play':
|
||||||
|
await _openReplay(r.replayUrl);
|
||||||
|
break;
|
||||||
|
case 'download':
|
||||||
|
if (!canDownload) return;
|
||||||
|
try {
|
||||||
|
final url = await ref.read(apiClientProvider).fetchRecordingDownloadUrl(r.id);
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Download: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'youtube':
|
||||||
|
final yt = r.youtubeWatchUrl;
|
||||||
|
if (yt == null) return;
|
||||||
|
final uri = Uri.parse(yt);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,13 @@ class ApiClient {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> fetchRecordingDownloadUrl(String recordingId) async {
|
||||||
|
final response = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/recordings/$recordingId/download',
|
||||||
|
);
|
||||||
|
return response.data!['download_url'] as String;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Matches ---
|
// --- Matches ---
|
||||||
|
|
||||||
Future<List<MatchModel>> fetchMatches(String teamId) async {
|
Future<List<MatchModel>> fetchMatches(String teamId) async {
|
||||||
|
|||||||
@@ -2,31 +2,72 @@ class RecordingItem {
|
|||||||
const RecordingItem({
|
const RecordingItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.sessionId,
|
required this.sessionId,
|
||||||
|
required this.title,
|
||||||
required this.opponentName,
|
required this.opponentName,
|
||||||
required this.teamName,
|
required this.teamName,
|
||||||
|
this.status = 'ready',
|
||||||
|
this.statusLabel = 'Disponibile',
|
||||||
|
this.privacyStatus = 'unlisted',
|
||||||
this.endedAt,
|
this.endedAt,
|
||||||
|
this.recordedAt,
|
||||||
this.replayUrl,
|
this.replayUrl,
|
||||||
|
this.playbackUrl,
|
||||||
|
this.thumbnailUrl,
|
||||||
|
this.durationLabel,
|
||||||
|
this.viewCount = 0,
|
||||||
|
this.viewsLabel,
|
||||||
|
this.downloadEnabled = false,
|
||||||
|
this.youtubeWatchUrl,
|
||||||
this.expiresAt,
|
this.expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String sessionId;
|
final String sessionId;
|
||||||
|
final String title;
|
||||||
final String opponentName;
|
final String opponentName;
|
||||||
final String teamName;
|
final String teamName;
|
||||||
|
final String status;
|
||||||
|
final String statusLabel;
|
||||||
|
final String privacyStatus;
|
||||||
final DateTime? endedAt;
|
final DateTime? endedAt;
|
||||||
|
final DateTime? recordedAt;
|
||||||
final String? replayUrl;
|
final String? replayUrl;
|
||||||
|
final String? playbackUrl;
|
||||||
|
final String? thumbnailUrl;
|
||||||
|
final String? durationLabel;
|
||||||
|
final int viewCount;
|
||||||
|
final String? viewsLabel;
|
||||||
|
final bool downloadEnabled;
|
||||||
|
final String? youtubeWatchUrl;
|
||||||
final DateTime? expiresAt;
|
final DateTime? expiresAt;
|
||||||
|
|
||||||
|
bool get isReady => status == 'ready';
|
||||||
|
bool get isProcessing => status == 'processing';
|
||||||
|
|
||||||
factory RecordingItem.fromJson(Map<String, dynamic> json) {
|
factory RecordingItem.fromJson(Map<String, dynamic> json) {
|
||||||
return RecordingItem(
|
return RecordingItem(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
sessionId: json['session_id'] as String,
|
sessionId: json['session_id'] as String,
|
||||||
|
title: json['title'] as String? ?? '${json['team_name']} vs ${json['opponent_name']}',
|
||||||
opponentName: json['opponent_name'] as String,
|
opponentName: json['opponent_name'] as String,
|
||||||
teamName: json['team_name'] as String,
|
teamName: json['team_name'] as String,
|
||||||
|
status: json['status'] as String? ?? 'ready',
|
||||||
|
statusLabel: json['status_label'] as String? ?? 'Disponibile',
|
||||||
|
privacyStatus: json['privacy_status'] as String? ?? 'unlisted',
|
||||||
endedAt: json['ended_at'] != null
|
endedAt: json['ended_at'] != null
|
||||||
? DateTime.parse(json['ended_at'] as String)
|
? DateTime.parse(json['ended_at'] as String)
|
||||||
: null,
|
: null,
|
||||||
|
recordedAt: json['recorded_at'] != null
|
||||||
|
? DateTime.parse(json['recorded_at'] as String)
|
||||||
|
: null,
|
||||||
replayUrl: json['replay_url'] as String?,
|
replayUrl: json['replay_url'] as String?,
|
||||||
|
playbackUrl: json['playback_url'] as String?,
|
||||||
|
thumbnailUrl: json['thumbnail_url'] as String?,
|
||||||
|
durationLabel: json['duration_label'] as String?,
|
||||||
|
viewCount: json['view_count'] as int? ?? 0,
|
||||||
|
viewsLabel: json['views_label'] as String?,
|
||||||
|
downloadEnabled: json['download_enabled'] as bool? ?? false,
|
||||||
|
youtubeWatchUrl: json['youtube_watch_url'] as String?,
|
||||||
expiresAt: json['expires_at'] != null
|
expiresAt: json['expires_at'] != null
|
||||||
? DateTime.parse(json['expires_at'] as String)
|
? DateTime.parse(json['expires_at'] as String)
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
Reference in New Issue
Block a user