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:
2026-06-08 09:33:59 +02:00
parent ae36d17adb
commit 74eee24293
17 changed files with 385 additions and 34 deletions

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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?,

View 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