Completa modulo Replay: S3, retention, sync YouTube e gestione società.
Streaming chunked da Garage, purge con delete YouTube, privacy sincronizzata, archivio club migliorato, retention 30/90 separata da stato abbonamento. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,7 @@ module Recordings
|
||||
end
|
||||
|
||||
def call
|
||||
delete_youtube_video
|
||||
delete_storage_object if @recording.storage_key.present?
|
||||
cleanup_local_artifacts
|
||||
|
||||
@@ -13,13 +14,23 @@ module Recordings
|
||||
status: "expired",
|
||||
deleted_at: Time.current,
|
||||
storage_key: nil,
|
||||
thumbnail_storage_key: nil
|
||||
thumbnail_storage_key: nil,
|
||||
youtube_video_id: nil,
|
||||
youtube_published_at: nil
|
||||
)
|
||||
@recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def delete_youtube_video
|
||||
return if @recording.youtube_video_id.blank?
|
||||
|
||||
Youtube::VideoLifecycleService.new(@recording).delete!
|
||||
rescue Youtube::VideoLifecycleService::Error => e
|
||||
Rails.logger.warn("[Recordings::Delete] YouTube delete #{@recording.id}: #{e.message}")
|
||||
end
|
||||
|
||||
def delete_storage_object
|
||||
storage = Recordings::Storage.new
|
||||
storage.delete(key: @recording.storage_key) if @recording.storage_key.present?
|
||||
|
||||
@@ -6,7 +6,7 @@ module Recordings
|
||||
|
||||
def call
|
||||
team = @session.match.team
|
||||
return unless team.entitlements.can_access_recordings?
|
||||
return unless team.entitlements.can_create_recordings?
|
||||
|
||||
retention_days = team.entitlements.recording_retention_days
|
||||
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
|
||||
|
||||
@@ -5,6 +5,8 @@ require "aws-sdk-s3"
|
||||
module Recordings
|
||||
# Serve file replay/thumbnail al browser senza esporre URL S3 interni (es. http://garage:3900).
|
||||
class ServeFromStorage
|
||||
CHUNK_SIZE = 256 * 1024
|
||||
|
||||
def initialize(key:, content_type:, disposition: "inline", filename: nil)
|
||||
@key = key
|
||||
@content_type = content_type
|
||||
@@ -14,7 +16,15 @@ module Recordings
|
||||
|
||||
def call(controller)
|
||||
path = local_path
|
||||
return controller.send_file(path, type: @content_type, disposition: @disposition, filename: @filename) if path
|
||||
if path
|
||||
return controller.send_file(
|
||||
path,
|
||||
type: @content_type,
|
||||
disposition: @disposition,
|
||||
filename: @filename,
|
||||
stream: true
|
||||
)
|
||||
end
|
||||
|
||||
proxy_s3(controller)
|
||||
end
|
||||
@@ -41,22 +51,24 @@ module Recordings
|
||||
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?
|
||||
controller.response.headers["Content-Disposition"] = content_disposition_header
|
||||
|
||||
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?
|
||||
|
||||
body = resp.body
|
||||
controller.response_body = Enumerator.new do |yielder|
|
||||
while (chunk = body.read(CHUNK_SIZE))
|
||||
yielder << chunk
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def content_disposition_header
|
||||
if @disposition == "attachment" && @filename.present?
|
||||
%(attachment; filename="#{@filename.gsub('"', '\\"')}")
|
||||
else
|
||||
"inline"
|
||||
end
|
||||
end
|
||||
|
||||
def s3_client
|
||||
@@ -66,7 +78,6 @@ module Recordings
|
||||
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
|
||||
|
||||
@@ -178,12 +178,18 @@ module Teams
|
||||
assert_can_stream_on!("youtube")
|
||||
end
|
||||
|
||||
def can_access_recordings?
|
||||
# Nuove registrazioni (MediaMTX + FinalizeSession): richiede abbonamento attivo.
|
||||
def can_create_recordings?
|
||||
active? && plan.recordings_enabled?
|
||||
end
|
||||
|
||||
# Archivio esistente: visibile fino a expires_at anche se l'abbonamento è scaduto.
|
||||
def can_access_recordings?
|
||||
plan.recordings_enabled? || @team.recordings.not_deleted.exists?
|
||||
end
|
||||
|
||||
def recording_enabled_for_mediamtx?
|
||||
can_access_recordings?
|
||||
can_create_recordings?
|
||||
end
|
||||
|
||||
def recording_retention_days
|
||||
@@ -215,6 +221,7 @@ module Teams
|
||||
concurrent_streams_used: concurrent_streams_used,
|
||||
concurrent_streams_limit: concurrent_streams_limit,
|
||||
recordings_enabled: can_access_recordings?,
|
||||
can_create_recordings: can_create_recordings?,
|
||||
recording_retention_days: recording_retention_days,
|
||||
phone_download_enabled: phone_download_enabled?,
|
||||
youtube_enabled: youtube_enabled?,
|
||||
|
||||
66
backend/app/services/youtube/video_lifecycle_service.rb
Normal file
66
backend/app/services/youtube/video_lifecycle_service.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
module Youtube
|
||||
# Aggiorna privacy o elimina un VOD YouTube collegato a un replay.
|
||||
class VideoLifecycleService
|
||||
class Error < StandardError; end
|
||||
|
||||
def initialize(recording)
|
||||
@recording = recording
|
||||
@team = recording.team
|
||||
@session = recording.stream_session
|
||||
end
|
||||
|
||||
def update_privacy!(privacy_status:)
|
||||
video_id = @recording.youtube_video_id
|
||||
return if video_id.blank? || video_id.to_s.start_with?("mock_")
|
||||
|
||||
privacy = map_privacy(privacy_status)
|
||||
client = authorized_client
|
||||
video = Google::Apis::YoutubeV3::Video.new(
|
||||
id: video_id,
|
||||
status: Google::Apis::YoutubeV3::VideoStatus.new(
|
||||
privacy_status: privacy,
|
||||
self_declared_made_for_kids: false
|
||||
)
|
||||
)
|
||||
client.update_video("status", video)
|
||||
merge_youtube_meta!("privacy_status" => privacy)
|
||||
true
|
||||
rescue Google::Apis::Error => e
|
||||
raise Error, e.message
|
||||
end
|
||||
|
||||
def delete!
|
||||
video_id = @recording.youtube_video_id
|
||||
return if video_id.blank? || video_id.to_s.start_with?("mock_")
|
||||
|
||||
client = authorized_client
|
||||
client.delete_video(video_id)
|
||||
true
|
||||
rescue Google::Apis::Error => e
|
||||
raise Error, e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def map_privacy(status)
|
||||
status.to_s == "public" ? "public" : "unlisted"
|
||||
end
|
||||
|
||||
def merge_youtube_meta!(attrs)
|
||||
meta = @recording.metadata.is_a?(Hash) ? @recording.metadata.dup : {}
|
||||
publish = meta.fetch("youtube_publish", {}).merge(attrs)
|
||||
@recording.update!(metadata: meta.merge("youtube_publish" => publish))
|
||||
end
|
||||
|
||||
def youtube_channel
|
||||
@session&.platform == "youtube" ? "team" : nil
|
||||
end
|
||||
|
||||
def authorized_client
|
||||
credential = CredentialResolver.new(@team, channel: youtube_channel).resolve
|
||||
raise Error, "Credenziali YouTube non disponibili" if credential.blank?
|
||||
|
||||
OauthRefresh.new(credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user