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:
33
backend/app/services/recordings/access_policy.rb
Normal file
33
backend/app/services/recordings/access_policy.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
module Recordings
|
||||
class AccessPolicy
|
||||
def initialize(recording, viewer: nil)
|
||||
@recording = recording
|
||||
@viewer = viewer
|
||||
end
|
||||
|
||||
def allowed?
|
||||
return false if @recording.deleted?
|
||||
return true if @recording.status == "processing" && (club_member? || manage_allowed?)
|
||||
return false unless @recording.ready?
|
||||
|
||||
return true if @recording.unlisted? || @recording.publicly_listed?
|
||||
|
||||
club_member?
|
||||
end
|
||||
|
||||
def manage_allowed?
|
||||
return false unless @viewer
|
||||
|
||||
team = @recording.team
|
||||
team.club.owned_by?(@viewer) || @viewer.manageable_teams.exists?(id: team.id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def club_member?
|
||||
return false unless @viewer
|
||||
|
||||
@recording.team.club.users.exists?(id: @viewer.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
20
backend/app/services/recordings/club_stats.rb
Normal file
20
backend/app/services/recordings/club_stats.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module Recordings
|
||||
class ClubStats
|
||||
def initialize(club)
|
||||
@club = club
|
||||
end
|
||||
|
||||
def call
|
||||
scope = Recording.for_club(@club).not_deleted
|
||||
ready = scope.ready
|
||||
{
|
||||
available_count: ready.count,
|
||||
expiring_soon_count: ready.expiring_within(7).count,
|
||||
total_bytes: ready.sum(:byte_size).to_i,
|
||||
total_views: ready.sum(:view_count).to_i,
|
||||
processing_count: scope.where(status: "processing").count,
|
||||
failed_count: scope.where(status: "failed").count
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
38
backend/app/services/recordings/delete.rb
Normal file
38
backend/app/services/recordings/delete.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
module Recordings
|
||||
class Delete
|
||||
def initialize(recording, reason: :manual)
|
||||
@recording = recording
|
||||
@reason = reason
|
||||
end
|
||||
|
||||
def call
|
||||
delete_storage_object if @recording.storage_key.present?
|
||||
cleanup_local_artifacts
|
||||
|
||||
@recording.update!(
|
||||
status: "expired",
|
||||
deleted_at: Time.current,
|
||||
storage_key: nil,
|
||||
thumbnail_storage_key: nil
|
||||
)
|
||||
@recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_storage_object
|
||||
storage = Recordings::Storage.new
|
||||
storage.delete(key: @recording.storage_key) if @recording.storage_key.present?
|
||||
storage.delete(key: @recording.thumbnail_storage_key) if @recording.thumbnail_storage_key.present?
|
||||
rescue Recordings::Storage::Error => e
|
||||
Rails.logger.warn("[Recordings::Delete] storage delete failed: #{e.message}")
|
||||
end
|
||||
|
||||
def cleanup_local_artifacts
|
||||
return if @recording.storage_path.blank?
|
||||
|
||||
base = File.join(MatchLiveTv.recordings_local_path, @recording.storage_path)
|
||||
FileUtils.rm_rf(base) if Dir.exist?(base)
|
||||
end
|
||||
end
|
||||
end
|
||||
28
backend/app/services/recordings/download_url.rb
Normal file
28
backend/app/services/recordings/download_url.rb
Normal file
@@ -0,0 +1,28 @@
|
||||
module Recordings
|
||||
class DownloadUrl
|
||||
class NotAllowed < StandardError; end
|
||||
|
||||
def initialize(recording, viewer: nil)
|
||||
@recording = recording
|
||||
@viewer = viewer
|
||||
end
|
||||
|
||||
def call
|
||||
raise NotAllowed, "Download non disponibile per questo piano" unless download_allowed?
|
||||
raise ArgumentError, "Replay non pronto" unless @recording.ready?
|
||||
raise ArgumentError, "File non disponibile" if @recording.storage_key.blank?
|
||||
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{@recording.stream_session_id}/download"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def download_allowed?
|
||||
ent = @recording.team.entitlements
|
||||
return false unless ent.phone_download_enabled?
|
||||
|
||||
policy = Recordings::AccessPolicy.new(@recording, viewer: @viewer)
|
||||
policy.allowed? || policy.manage_allowed?
|
||||
end
|
||||
end
|
||||
end
|
||||
51
backend/app/services/recordings/finalize_session.rb
Normal file
51
backend/app/services/recordings/finalize_session.rb
Normal file
@@ -0,0 +1,51 @@
|
||||
module Recordings
|
||||
class FinalizeSession
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
team = @session.match.team
|
||||
return unless team.entitlements.can_access_recordings?
|
||||
|
||||
retention_days = team.entitlements.recording_retention_days
|
||||
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
|
||||
|
||||
recording = Recording.find_or_initialize_by(stream_session: @session)
|
||||
recording.assign_attributes(
|
||||
team: team,
|
||||
status: "processing",
|
||||
title: default_title,
|
||||
privacy_status: privacy_from_session,
|
||||
storage_path: @session.mediamtx_path_name,
|
||||
recorded_at: @session.ended_at || Time.current,
|
||||
expires_at: expires_at,
|
||||
error_message: nil,
|
||||
metadata: initial_metadata(team)
|
||||
)
|
||||
recording.save!
|
||||
recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def default_title
|
||||
match = @session.match
|
||||
"#{match.team.name} vs #{match.opponent_name}"
|
||||
end
|
||||
|
||||
def privacy_from_session
|
||||
@session.privacy_status == "public" ? "public" : "unlisted"
|
||||
end
|
||||
|
||||
def initial_metadata(team)
|
||||
ent = team.entitlements
|
||||
{
|
||||
"source_platform" => @session.platform,
|
||||
"session_privacy" => @session.privacy_status,
|
||||
"auto_publish_youtube" => ent.premium_full? && ent.youtube_enabled? && @session.platform == "youtube",
|
||||
"ai" => {}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
55
backend/app/services/recordings/generate_thumbnail.rb
Normal file
55
backend/app/services/recordings/generate_thumbnail.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
module Recordings
|
||||
class GenerateThumbnail
|
||||
class Error < StandardError; end
|
||||
|
||||
def initialize(recording, video_path: nil)
|
||||
@recording = recording
|
||||
@video_path = video_path
|
||||
end
|
||||
|
||||
def call
|
||||
return @recording if @recording.thumbnail_storage_key.present?
|
||||
|
||||
path = @video_path || local_video_path
|
||||
return @recording unless path && File.exist?(path)
|
||||
|
||||
thumb_path = extract_frame(path)
|
||||
key = thumbnail_key
|
||||
Recordings::Storage.new.upload(local_path: thumb_path, key: key, content_type: "image/jpeg")
|
||||
@recording.update!(thumbnail_storage_key: key)
|
||||
FileUtils.rm_f(thumb_path)
|
||||
@recording
|
||||
rescue Recordings::Storage::Error => e
|
||||
Rails.logger.warn("[Recordings::GenerateThumbnail] #{@recording.id}: #{e.message}")
|
||||
@recording
|
||||
ensure
|
||||
FileUtils.rm_f(@thumb_temp) if @thumb_temp && File.exist?(@thumb_temp)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def local_video_path
|
||||
return nil if @recording.storage_key.blank?
|
||||
|
||||
Recordings::Storage.new.local_path_for_key(@recording.storage_key)
|
||||
end
|
||||
|
||||
def extract_frame(video_path)
|
||||
@thumb_temp = File.join(Dir.tmpdir, "thumb-#{@recording.id}-#{SecureRandom.hex(4)}.jpg")
|
||||
dur = @recording.duration_secs.to_i
|
||||
offset = dur.positive? ? [[dur / 4, 5].max, dur - 1].min : 1
|
||||
success = system(
|
||||
"ffmpeg", "-y", "-ss", offset.to_s, "-i", video_path,
|
||||
"-vframes", "1", "-q:v", "2", @thumb_temp,
|
||||
out: File::NULL, err: File::NULL
|
||||
)
|
||||
raise Error, "ffmpeg thumbnail failed" unless success && File.exist?(@thumb_temp)
|
||||
|
||||
@thumb_temp
|
||||
end
|
||||
|
||||
def thumbnail_key
|
||||
"teams/#{@recording.team_id}/sessions/#{@recording.stream_session_id}/thumbnail.jpg"
|
||||
end
|
||||
end
|
||||
end
|
||||
14
backend/app/services/recordings/increment_views.rb
Normal file
14
backend/app/services/recordings/increment_views.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
module Recordings
|
||||
class IncrementViews
|
||||
def initialize(recording)
|
||||
@recording = recording
|
||||
end
|
||||
|
||||
def call
|
||||
return unless @recording.ready?
|
||||
|
||||
Recording.where(id: @recording.id).update_all("view_count = view_count + 1")
|
||||
@recording.reload
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,25 +1,12 @@
|
||||
module Recordings
|
||||
# @deprecated Use Recordings::FinalizeSession
|
||||
class IndexSession
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
team = @session.match.team
|
||||
return unless team.entitlements.can_access_recordings?
|
||||
|
||||
retention_days = team.entitlements.recording_retention_days
|
||||
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
|
||||
|
||||
recording = Recording.find_or_initialize_by(stream_session: @session)
|
||||
recording.assign_attributes(
|
||||
team: team,
|
||||
status: "ready",
|
||||
storage_path: @session.mediamtx_path_name,
|
||||
expires_at: expires_at
|
||||
)
|
||||
recording.save!
|
||||
recording
|
||||
FinalizeSession.new(@session).call
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
33
backend/app/services/recordings/notify_expiring.rb
Normal file
33
backend/app/services/recordings/notify_expiring.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
module Recordings
|
||||
class NotifyExpiring
|
||||
WINDOW_DAYS = 7
|
||||
|
||||
def initialize(recording)
|
||||
@recording = recording
|
||||
end
|
||||
|
||||
def call
|
||||
return @recording if @recording.expiry_warning_sent_at.present?
|
||||
return @recording unless @recording.ready?
|
||||
return @recording if @recording.expires_at.blank?
|
||||
return @recording unless @recording.expires_at <= WINDOW_DAYS.days.from_now
|
||||
|
||||
recipients.each do |user|
|
||||
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now
|
||||
end
|
||||
|
||||
@recording.update!(expiry_warning_sent_at: Time.current)
|
||||
@recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recipients
|
||||
club = @recording.team.club
|
||||
owner = club.owner
|
||||
users = club.users.distinct.to_a
|
||||
users << owner if owner && users.none? { |u| u.id == owner.id }
|
||||
users.uniq.select { |u| u.email.present? }
|
||||
end
|
||||
end
|
||||
end
|
||||
29
backend/app/services/recordings/notify_ready.rb
Normal file
29
backend/app/services/recordings/notify_ready.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
module Recordings
|
||||
class NotifyReady
|
||||
def initialize(recording)
|
||||
@recording = recording
|
||||
end
|
||||
|
||||
def call
|
||||
return @recording if @recording.ready_notified_at.present?
|
||||
return @recording unless @recording.ready?
|
||||
|
||||
recipients.each do |user|
|
||||
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now
|
||||
end
|
||||
|
||||
@recording.update!(ready_notified_at: Time.current)
|
||||
@recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recipients
|
||||
club = @recording.team.club
|
||||
owner = club.owner
|
||||
users = club.users.distinct.to_a
|
||||
users << owner if owner && users.none? { |u| u.id == owner.id }
|
||||
users.uniq.select { |u| u.email.present? }
|
||||
end
|
||||
end
|
||||
end
|
||||
20
backend/app/services/recordings/playback_url.rb
Normal file
20
backend/app/services/recordings/playback_url.rb
Normal file
@@ -0,0 +1,20 @@
|
||||
module Recordings
|
||||
class PlaybackUrl
|
||||
def initialize(recording)
|
||||
@recording = recording
|
||||
end
|
||||
|
||||
def call
|
||||
raise ArgumentError, "Replay non pronto" unless @recording.ready?
|
||||
raise ArgumentError, "Storage key mancante" if @recording.storage_key.blank?
|
||||
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/replay/#{@recording.stream_session_id}/stream"
|
||||
end
|
||||
|
||||
def local_path
|
||||
storage = Recordings::Storage.new
|
||||
path = storage.local_path_for_key(@recording.storage_key)
|
||||
path if path && File.exist?(path)
|
||||
end
|
||||
end
|
||||
end
|
||||
79
backend/app/services/recordings/publish_to_youtube.rb
Normal file
79
backend/app/services/recordings/publish_to_youtube.rb
Normal file
@@ -0,0 +1,79 @@
|
||||
module Recordings
|
||||
class PublishToYoutube
|
||||
class Error < StandardError; end
|
||||
|
||||
def initialize(recording, privacy_status: nil)
|
||||
@recording = recording
|
||||
@privacy_status = privacy_status
|
||||
end
|
||||
|
||||
def call
|
||||
team = @recording.team
|
||||
ent = team.entitlements
|
||||
raise Error, "Republicazione YouTube disponibile con Premium Full" unless ent.premium_full? && ent.youtube_enabled?
|
||||
raise Error, "Replay già pubblicato su YouTube" if @recording.youtube_video_id.present?
|
||||
raise Error, "Replay non pronto" unless @recording.ready?
|
||||
|
||||
video_path = local_video_path
|
||||
raise Error, "File video non disponibile in locale; riprova dopo l’upload" unless video_path
|
||||
|
||||
session = @recording.stream_session
|
||||
privacy = @privacy_status.presence || youtube_privacy
|
||||
channel = session.platform == "youtube" ? "team" : nil
|
||||
|
||||
result = Youtube::VideoUploadService.new(team, youtube_channel: channel).upload_file!(
|
||||
file_path: video_path,
|
||||
title: @recording.title_or_default,
|
||||
description: youtube_description(session),
|
||||
privacy_status: privacy,
|
||||
tags: %w[MatchLiveTV replay sport]
|
||||
)
|
||||
|
||||
meta = @recording.metadata.merge(
|
||||
"youtube_publish" => {
|
||||
"video_id" => result[:video_id],
|
||||
"watch_url" => result[:watch_url],
|
||||
"published_at" => Time.current.iso8601,
|
||||
"privacy_status" => privacy
|
||||
}
|
||||
)
|
||||
|
||||
@recording.update!(
|
||||
youtube_video_id: result[:video_id],
|
||||
youtube_published_at: Time.current,
|
||||
metadata: meta
|
||||
)
|
||||
@recording
|
||||
ensure
|
||||
FileUtils.rm_f(@temp_video) if @temp_video && File.exist?(@temp_video)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def local_video_path
|
||||
return @temp_video if @temp_video && File.exist?(@temp_video)
|
||||
|
||||
if @recording.storage_key.blank?
|
||||
return nil
|
||||
end
|
||||
|
||||
storage = Recordings::Storage.new
|
||||
path = storage.local_path_for_key(@recording.storage_key)
|
||||
return path if path && File.exist?(path)
|
||||
|
||||
@temp_video = storage.download_to_temp(key: @recording.storage_key)
|
||||
@temp_video
|
||||
end
|
||||
|
||||
def youtube_privacy
|
||||
@recording.publicly_listed? ? "public" : "unlisted"
|
||||
end
|
||||
|
||||
def youtube_description(session)
|
||||
match = session.match
|
||||
base = "#{match.team.name} vs #{match.opponent_name}"
|
||||
url = @recording.replay_url
|
||||
[base, url, "Trasmesso con Match Live TV"].compact.join("\n\n")
|
||||
end
|
||||
end
|
||||
end
|
||||
74
backend/app/services/recordings/serve_from_storage.rb
Normal file
74
backend/app/services/recordings/serve_from_storage.rb
Normal file
@@ -0,0 +1,74 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "aws-sdk-s3"
|
||||
|
||||
module Recordings
|
||||
# Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900).
|
||||
class ServeFromStorage
|
||||
def initialize(key:, content_type:, disposition: "inline", filename: nil)
|
||||
@key = key
|
||||
@content_type = content_type
|
||||
@disposition = disposition
|
||||
@filename = filename
|
||||
end
|
||||
|
||||
def call(controller)
|
||||
path = local_path
|
||||
return controller.send_file(path, type: @content_type, disposition: @disposition, filename: @filename) if path
|
||||
|
||||
proxy_s3(controller)
|
||||
end
|
||||
|
||||
def local_path
|
||||
path = Recordings::Storage.new.local_path_for_key(@key)
|
||||
path if path && File.exist?(path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def proxy_s3(controller)
|
||||
raise ArgumentError, "Storage key mancante" if @key.blank?
|
||||
raise ArgumentError, "Storage S3 non configurato" if MatchLiveTv.replay_storage_local?
|
||||
|
||||
range = controller.request.headers["Range"]
|
||||
resp = s3_client.get_object(
|
||||
bucket: MatchLiveTv.replay_storage_bucket,
|
||||
key: @key,
|
||||
range: range.presence
|
||||
)
|
||||
|
||||
controller.response.headers["Content-Type"] = @content_type
|
||||
controller.response.headers["Accept-Ranges"] = "bytes"
|
||||
controller.response.headers["Content-Length"] = resp.content_length.to_s if resp.content_length
|
||||
controller.response.headers["Content-Range"] = resp.content_range if resp.content_range.present?
|
||||
|
||||
data = resp.body.read
|
||||
max = 1.gigabyte
|
||||
raise ArgumentError, "File troppo grande per il proxy" if data.bytesize > max
|
||||
|
||||
if @disposition == "attachment" && @filename.present?
|
||||
controller.send_data(
|
||||
data,
|
||||
type: @content_type,
|
||||
disposition: "attachment",
|
||||
filename: @filename
|
||||
)
|
||||
else
|
||||
controller.send_data(data, type: @content_type, disposition: "inline")
|
||||
end
|
||||
controller.status = :partial_content if range.present? && resp.content_range.present?
|
||||
end
|
||||
|
||||
def s3_client
|
||||
@s3_client ||= Aws::S3::Client.new(
|
||||
access_key_id: MatchLiveTv.replay_storage_access_key_id,
|
||||
secret_access_key: MatchLiveTv.replay_storage_secret_access_key,
|
||||
endpoint: MatchLiveTv.replay_storage_endpoint,
|
||||
region: MatchLiveTv.replay_storage_region,
|
||||
force_path_style: MatchLiveTv.replay_storage_force_path_style?,
|
||||
# Garage non sempre allinea i checksum AWS su Range/read parziali
|
||||
response_checksum_validation: "WHEN_REQUIRED"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
123
backend/app/services/recordings/storage.rb
Normal file
123
backend/app/services/recordings/storage.rb
Normal file
@@ -0,0 +1,123 @@
|
||||
require "open-uri"
|
||||
require "aws-sdk-s3"
|
||||
|
||||
module Recordings
|
||||
class Storage
|
||||
class Error < StandardError; end
|
||||
|
||||
def initialize
|
||||
@backend = MatchLiveTv.replay_storage_local? ? LocalBackend.new : S3Backend.new
|
||||
end
|
||||
|
||||
def upload(local_path:, key:, content_type: "video/mp4")
|
||||
@backend.upload(local_path: local_path, key: key, content_type: content_type)
|
||||
end
|
||||
|
||||
def delete(key:)
|
||||
@backend.delete(key: key)
|
||||
end
|
||||
|
||||
def download_to_temp(key:)
|
||||
local = local_path_for_key(key)
|
||||
return local if local && File.exist?(local)
|
||||
|
||||
url = presigned_get_url(key: key, expires_in: 600)
|
||||
raise Error, "Impossibile scaricare file dallo storage" if url.blank?
|
||||
|
||||
dest = File.join(Dir.tmpdir, "replay-dl-#{SecureRandom.hex(8)}.mp4")
|
||||
URI.open(url) do |remote|
|
||||
File.binwrite(dest, remote.read)
|
||||
end
|
||||
dest
|
||||
end
|
||||
|
||||
def presigned_get_url(key:, expires_in: MatchLiveTv.replay_presigned_url_ttl_seconds)
|
||||
@backend.presigned_get_url(key: key, expires_in: expires_in)
|
||||
end
|
||||
|
||||
def local_path_for_key(key)
|
||||
@backend.local_path_for_key(key)
|
||||
end
|
||||
|
||||
def backend_name
|
||||
@backend.name
|
||||
end
|
||||
|
||||
class LocalBackend
|
||||
def name
|
||||
"local"
|
||||
end
|
||||
|
||||
def upload(local_path:, key:, content_type:)
|
||||
dest = local_path_for_key(key)
|
||||
FileUtils.mkdir_p(File.dirname(dest))
|
||||
FileUtils.cp(local_path, dest)
|
||||
File.size(dest)
|
||||
end
|
||||
|
||||
def delete(key:)
|
||||
path = local_path_for_key(key)
|
||||
FileUtils.rm_f(path)
|
||||
true
|
||||
end
|
||||
|
||||
def presigned_get_url(key:, expires_in:)
|
||||
nil
|
||||
end
|
||||
|
||||
def local_path_for_key(key)
|
||||
File.join(MatchLiveTv.recordings_local_path, "replays", key)
|
||||
end
|
||||
end
|
||||
|
||||
class S3Backend
|
||||
def name
|
||||
"s3"
|
||||
end
|
||||
|
||||
def upload(local_path:, key:, content_type:)
|
||||
client.put_object(
|
||||
bucket: MatchLiveTv.replay_storage_bucket,
|
||||
key: key,
|
||||
body: File.open(local_path, "rb"),
|
||||
content_type: content_type
|
||||
)
|
||||
File.size(local_path)
|
||||
end
|
||||
|
||||
def delete(key:)
|
||||
client.delete_object(bucket: MatchLiveTv.replay_storage_bucket, key: key)
|
||||
true
|
||||
rescue Aws::S3::Errors::ServiceError => e
|
||||
raise Error, e.message
|
||||
end
|
||||
|
||||
def presigned_get_url(key:, expires_in:)
|
||||
signer = Aws::S3::Presigner.new(client: client)
|
||||
signer.presigned_url(
|
||||
:get_object,
|
||||
bucket: MatchLiveTv.replay_storage_bucket,
|
||||
key: key,
|
||||
expires_in: expires_in
|
||||
)
|
||||
end
|
||||
|
||||
def local_path_for_key(_key)
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
@client ||= Aws::S3::Client.new(
|
||||
access_key_id: MatchLiveTv.replay_storage_access_key_id,
|
||||
secret_access_key: MatchLiveTv.replay_storage_secret_access_key,
|
||||
endpoint: MatchLiveTv.replay_storage_endpoint,
|
||||
region: MatchLiveTv.replay_storage_region,
|
||||
force_path_style: MatchLiveTv.replay_storage_force_path_style?,
|
||||
response_checksum_validation: "WHEN_REQUIRED"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
104
backend/app/services/recordings/upload_from_session.rb
Normal file
104
backend/app/services/recordings/upload_from_session.rb
Normal file
@@ -0,0 +1,104 @@
|
||||
module Recordings
|
||||
class UploadFromSession
|
||||
class Error < StandardError; end
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
recording = Recording.find_by(stream_session: @session)
|
||||
return unless recording&.status == "processing"
|
||||
|
||||
source_files = local_source_files
|
||||
if source_files.empty?
|
||||
fail_recording!(recording, "Nessun file di registrazione trovato")
|
||||
cleanup_mediamtx_path
|
||||
return recording
|
||||
end
|
||||
|
||||
merged_path = merge_source_files(source_files)
|
||||
duration_secs = probe_duration(merged_path)
|
||||
storage_key = object_key(recording)
|
||||
storage = Recordings::Storage.new
|
||||
byte_size = storage.upload(local_path: merged_path, key: storage_key)
|
||||
|
||||
Recordings::GenerateThumbnail.new(recording, video_path: merged_path).call
|
||||
|
||||
recording.update!(
|
||||
status: "ready",
|
||||
storage_key: storage_key,
|
||||
storage_bucket: MatchLiveTv.replay_storage_bucket,
|
||||
storage_backend: storage.backend_name,
|
||||
byte_size: byte_size,
|
||||
duration_secs: duration_secs,
|
||||
error_message: nil
|
||||
)
|
||||
|
||||
cleanup_local_sources(source_files, merged_path)
|
||||
cleanup_mediamtx_path
|
||||
Recordings::PostProcessJob.perform_async(recording.id)
|
||||
recording
|
||||
rescue Error, Recordings::Storage::Error => e
|
||||
recording = Recording.find_by(stream_session: @session)
|
||||
fail_recording!(recording, e.message) if recording
|
||||
raise
|
||||
ensure
|
||||
FileUtils.rm_f(@merged_temp_path) if @merged_temp_path && File.exist?(@merged_temp_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def local_source_files
|
||||
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
|
||||
return [] unless Dir.exist?(base)
|
||||
|
||||
Dir.glob(File.join(base, "**", "*"))
|
||||
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
|
||||
.sort_by { |path| File.basename(path) }
|
||||
end
|
||||
|
||||
def merge_source_files(source_files)
|
||||
return source_files.first if source_files.one?
|
||||
|
||||
@merged_temp_path = File.join(Dir.tmpdir, "replay-#{@session.id}-#{SecureRandom.hex(4)}.mp4")
|
||||
list_path = "#{@merged_temp_path}.txt"
|
||||
File.write(list_path, source_files.map { |f| "file '#{f.gsub("'", "'\\''")}'" }.join("\n"))
|
||||
success = system("ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", list_path, "-c", "copy", @merged_temp_path,
|
||||
out: File::NULL, err: File::NULL)
|
||||
FileUtils.rm_f(list_path)
|
||||
raise Error, "Impossibile unire i segmenti video" unless success && File.exist?(@merged_temp_path)
|
||||
|
||||
@merged_temp_path
|
||||
end
|
||||
|
||||
def probe_duration(path)
|
||||
output = `ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "#{path}" 2>/dev/null`
|
||||
output.to_f.round
|
||||
rescue StandardError
|
||||
@session.total_duration_secs.to_i
|
||||
end
|
||||
|
||||
def object_key(recording)
|
||||
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
|
||||
end
|
||||
|
||||
def cleanup_local_sources(source_files, merged_path)
|
||||
source_files.each { |path| FileUtils.rm_f(path) }
|
||||
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
|
||||
FileUtils.rm_rf(base) if Dir.exist?(base)
|
||||
FileUtils.rm_f(merged_path) if merged_path != source_files.first
|
||||
end
|
||||
|
||||
def cleanup_mediamtx_path
|
||||
Mediamtx::Client.new.delete_path(@session)
|
||||
rescue Mediamtx::Client::Error => e
|
||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||
end
|
||||
|
||||
def fail_recording!(recording, message)
|
||||
recording.update!(status: "failed", error_message: message)
|
||||
cleanup_mediamtx_path
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user