Aggiunge replay YouTube temporaneo, pull registrazioni dai CPX e snapshot ingest in admin.
Così overflow Hetzner e VOD YouTube restano in archivio dopo lo spegnimento del nodo, e la colonna ingest non si svuota. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
# Rimuove solo l'MP4 temporaneo su object storage. Non soft-delete, non tocca YouTube.
|
||||
class ClearTemporaryMedia
|
||||
def initialize(recording, reason: :verified, force: false)
|
||||
@recording = recording
|
||||
@reason = reason
|
||||
@force = force
|
||||
end
|
||||
|
||||
def call
|
||||
unless @recording.temporary_storage?
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] skip recording=#{@recording.id} not_temporary"
|
||||
)
|
||||
return @recording
|
||||
end
|
||||
|
||||
if @recording.local_media_purged?
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] already_purged recording=#{@recording.id}"
|
||||
)
|
||||
return @recording
|
||||
end
|
||||
|
||||
unless allowed?
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] skip recording=#{@recording.id} " \
|
||||
"reason=not_verified_and_not_expired force=#{@force}"
|
||||
)
|
||||
return @recording
|
||||
end
|
||||
|
||||
delete_video_object!
|
||||
@recording.update!(
|
||||
storage_key: nil,
|
||||
byte_size: nil,
|
||||
local_media_purged_at: Time.current
|
||||
)
|
||||
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] purged recording=#{@recording.id} " \
|
||||
"reason=#{@reason} youtube_verified=#{@recording.youtube_verified_at.present?} " \
|
||||
"youtube_video_id=#{@recording.youtube_video_id.inspect}"
|
||||
)
|
||||
@recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def allowed?
|
||||
return true if @force
|
||||
return true if @recording.youtube_verified_at.present?
|
||||
return true if @recording.temp_expires_at.present? && @recording.temp_expires_at <= Time.current
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def delete_video_object!
|
||||
key = @recording.storage_key
|
||||
return if key.blank?
|
||||
|
||||
Recordings::Storage.new.delete(key: key)
|
||||
rescue Recordings::Storage::Error => e
|
||||
# File già assente → ok (idempotente)
|
||||
Rails.logger.info(
|
||||
"[Recordings::ClearTemporaryMedia] storage delete recording=#{@recording.id}: #{e.message}"
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,5 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
class FinalizeSession
|
||||
def initialize(session)
|
||||
@@ -5,29 +7,69 @@ module Recordings
|
||||
end
|
||||
|
||||
def call
|
||||
team = @session.match.team
|
||||
return unless team.entitlements.can_create_recordings?
|
||||
policy = Recordings::StoragePolicy.call(@session)
|
||||
return if policy == Recordings::StoragePolicy::NONE
|
||||
|
||||
retention_days = team.entitlements.recording_retention_days
|
||||
expires_at = retention_days.positive? ? retention_days.days.from_now : nil
|
||||
team = @session.match.team
|
||||
attrs = attributes_for(policy, team)
|
||||
|
||||
recording = Recording.find_or_initialize_by(stream_session: @session)
|
||||
recording.assign_attributes(
|
||||
if skip_reinitialize?(recording)
|
||||
Rails.logger.info(
|
||||
"[Recordings::FinalizeSession] skip already-finalized " \
|
||||
"recording=#{recording.id} status=#{recording.status}"
|
||||
)
|
||||
return recording
|
||||
end
|
||||
recording.assign_attributes(attrs)
|
||||
recording.save!
|
||||
|
||||
Rails.logger.info(
|
||||
"[Recordings::FinalizeSession] session=#{@session.id} recording=#{recording.id} " \
|
||||
"storage_policy=#{policy} expires_at=#{recording.expires_at.inspect} " \
|
||||
"temp_expires_at=#{recording.temp_expires_at.inspect}"
|
||||
)
|
||||
recording
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def skip_reinitialize?(recording)
|
||||
return false unless recording.persisted?
|
||||
|
||||
recording.ready? ||
|
||||
recording.status == "processing" ||
|
||||
recording.storage_key.present?
|
||||
end
|
||||
|
||||
def attributes_for(policy, team)
|
||||
{
|
||||
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,
|
||||
storage_policy: policy,
|
||||
expires_at: archive_expires_at(policy, team),
|
||||
temp_expires_at: temp_expires_at_for(policy),
|
||||
error_message: nil,
|
||||
metadata: initial_metadata(team)
|
||||
)
|
||||
recording.save!
|
||||
recording
|
||||
metadata: initial_metadata(policy, team)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def archive_expires_at(policy, team)
|
||||
return nil if policy == Recordings::StoragePolicy::TEMPORARY
|
||||
|
||||
retention_days = team.entitlements.recording_retention_days
|
||||
retention_days.positive? ? retention_days.days.from_now : nil
|
||||
end
|
||||
|
||||
def temp_expires_at_for(policy)
|
||||
return nil unless policy == Recordings::StoragePolicy::TEMPORARY
|
||||
|
||||
MatchLiveTv.youtube_temp_replay_retention_hours.hours.from_now
|
||||
end
|
||||
|
||||
def default_title
|
||||
match = @session.match
|
||||
@@ -38,12 +80,13 @@ module Recordings
|
||||
@session.privacy_status == "public" ? "public" : "unlisted"
|
||||
end
|
||||
|
||||
def initial_metadata(team)
|
||||
ent = team.entitlements
|
||||
def initial_metadata(policy, team)
|
||||
{
|
||||
"source_platform" => @session.platform,
|
||||
"session_privacy" => @session.privacy_status,
|
||||
"auto_publish_youtube" => ent.premium_full? && ent.youtube_enabled? && @session.platform == "youtube",
|
||||
# Re-upload automatico disabilitato: le live YouTube usano VerifyYoutubeReplay.
|
||||
"auto_publish_youtube" => false,
|
||||
"storage_policy" => policy,
|
||||
"ai" => {}
|
||||
}
|
||||
end
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "cgi"
|
||||
require "fileutils"
|
||||
require "net/http"
|
||||
require "uri"
|
||||
|
||||
module Recordings
|
||||
# Recupera i segmenti MediaMTX dal disco del CPX (agent :9100) verso un tmpdir locale.
|
||||
class PullFromCloudNode
|
||||
class Error < StandardError; end
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def applicable?
|
||||
node = @session.stream_node
|
||||
node.present? && node.role == "cloud" && agent_url.present?
|
||||
end
|
||||
|
||||
# @return [String, nil] directory con i file, o nil se il nodo non è cloud / 404
|
||||
def fetch
|
||||
return unless applicable?
|
||||
|
||||
dest = Dir.mktmpdir("mltv-cpx-rec-")
|
||||
uri = recordings_uri
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.open_timeout = 5
|
||||
http.read_timeout = ENV.fetch("STREAM_NODE_RECORDINGS_PULL_TIMEOUT", "180").to_i
|
||||
req = Net::HTTP::Get.new(uri)
|
||||
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
|
||||
res = http.request(req)
|
||||
if res.is_a?(Net::HTTPNotFound)
|
||||
FileUtils.remove_entry(dest)
|
||||
return nil
|
||||
end
|
||||
unless res.is_a?(Net::HTTPSuccess) && res.body.present?
|
||||
FileUtils.remove_entry(dest)
|
||||
raise Error, "agent GET recordings HTTP #{res.code} #{res.body.to_s.truncate(200)}"
|
||||
end
|
||||
|
||||
tar_path = File.join(dest, "recordings.tar.gz")
|
||||
File.binwrite(tar_path, res.body)
|
||||
unpack!(tar_path, dest)
|
||||
FileUtils.rm_f(tar_path)
|
||||
dest
|
||||
rescue StandardError
|
||||
FileUtils.remove_entry(dest) if dest && Dir.exist?(dest)
|
||||
raise
|
||||
end
|
||||
|
||||
def cleanup_remote!
|
||||
return unless applicable?
|
||||
|
||||
uri = recordings_uri
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.open_timeout = 5
|
||||
http.read_timeout = 15
|
||||
req = Net::HTTP::Delete.new(uri)
|
||||
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
|
||||
res = http.request(req)
|
||||
return if res.is_a?(Net::HTTPSuccess) || res.is_a?(Net::HTTPNotFound)
|
||||
|
||||
Rails.logger.warn(
|
||||
"[Recordings::PullFromCloudNode] delete HTTP #{res.code} session=#{@session.id}"
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn("[Recordings::PullFromCloudNode] delete #{e.class}: #{e.message}")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recordings_uri
|
||||
path = @session.mediamtx_path_name.to_s
|
||||
URI.parse("#{agent_url.chomp('/')}/recordings/#{CGI.escape(path)}")
|
||||
end
|
||||
|
||||
def agent_url
|
||||
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || @session.stream_node&.relay_agent_url
|
||||
end
|
||||
|
||||
def agent_secret
|
||||
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
|
||||
end
|
||||
|
||||
def unpack!(tar_path, dest)
|
||||
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
|
||||
raise Error, "tar extract failed" unless ok
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
# Decisione centralizzata: temporary (YouTube) | retained (HLS) | none.
|
||||
class StoragePolicy
|
||||
TEMPORARY = "temporary"
|
||||
RETAINED = "retained"
|
||||
NONE = "none"
|
||||
POLICIES = [TEMPORARY, RETAINED, NONE].freeze
|
||||
|
||||
def self.call(session)
|
||||
new(session).call
|
||||
end
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
def call
|
||||
team = @session.match.team
|
||||
ent = team.entitlements
|
||||
|
||||
return NONE unless ent.can_create_recordings?
|
||||
|
||||
if youtube_destination?
|
||||
TEMPORARY
|
||||
else
|
||||
RETAINED
|
||||
end
|
||||
end
|
||||
|
||||
def youtube_destination?
|
||||
@session.platform.to_s == "youtube"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,8 +9,9 @@ module Recordings
|
||||
def call
|
||||
recording = Recording.find_by(stream_session: @session)
|
||||
return unless recording&.status == "processing"
|
||||
return if recording.storage_key.present?
|
||||
|
||||
source_files = local_source_files
|
||||
source_files = collect_source_files
|
||||
if source_files.empty?
|
||||
fail_recording!(recording, "Nessun file di registrazione trovato")
|
||||
cleanup_mediamtx_path
|
||||
@@ -35,23 +36,52 @@ module Recordings
|
||||
error_message: nil
|
||||
)
|
||||
|
||||
Rails.logger.info(
|
||||
"[Recordings::UploadFromSession] ready recording=#{recording.id} " \
|
||||
"storage_policy=#{recording.storage_policy} key=#{storage_key} bytes=#{byte_size}"
|
||||
)
|
||||
|
||||
cleanup_local_sources(source_files, merged_path)
|
||||
cleanup_cloud_pull!
|
||||
cleanup_remote_recordings!
|
||||
cleanup_mediamtx_path
|
||||
Recordings::PostProcessJob.perform_async(recording.id)
|
||||
recording
|
||||
rescue Error, Recordings::Storage::Error => e
|
||||
rescue Error, Recordings::Storage::Error, Recordings::PullFromCloudNode::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)
|
||||
cleanup_cloud_pull! unless recording_ready?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def collect_source_files
|
||||
files = local_source_files
|
||||
return files if files.any?
|
||||
|
||||
puller = Recordings::PullFromCloudNode.new(@session)
|
||||
return [] unless puller.applicable?
|
||||
|
||||
3.times do |i|
|
||||
cleanup_cloud_pull!
|
||||
@cloud_pull_dir = puller.fetch
|
||||
files = scan_recording_files(@cloud_pull_dir)
|
||||
return files if files.any?
|
||||
|
||||
sleep 2 if i < 2
|
||||
end
|
||||
[]
|
||||
end
|
||||
|
||||
def local_source_files
|
||||
base = File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name)
|
||||
return [] unless Dir.exist?(base)
|
||||
scan_recording_files(File.join(MatchLiveTv.recordings_local_path, @session.mediamtx_path_name))
|
||||
end
|
||||
|
||||
def scan_recording_files(base)
|
||||
return [] if base.blank? || !Dir.exist?(base)
|
||||
|
||||
Dir.glob(File.join(base, "**", "*"))
|
||||
.select { |path| File.file?(path) && path.match?(/\.(mp4|fmp4|m4s|ts)$/i) }
|
||||
@@ -83,7 +113,11 @@ module Recordings
|
||||
end
|
||||
|
||||
def object_key(recording)
|
||||
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
|
||||
if recording.temporary_storage?
|
||||
"temporary_replays/teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
|
||||
else
|
||||
"teams/#{recording.team_id}/sessions/#{@session.id}/replay.mp4"
|
||||
end
|
||||
end
|
||||
|
||||
def cleanup_local_sources(source_files, merged_path)
|
||||
@@ -100,6 +134,24 @@ module Recordings
|
||||
Rails.logger.warn("[Recordings::UploadFromSession] delete_path: #{e.message}")
|
||||
end
|
||||
|
||||
def cleanup_cloud_pull!
|
||||
return if @cloud_pull_dir.blank? || !Dir.exist?(@cloud_pull_dir)
|
||||
|
||||
FileUtils.remove_entry(@cloud_pull_dir)
|
||||
@cloud_pull_dir = nil
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def cleanup_remote_recordings!
|
||||
Recordings::PullFromCloudNode.new(@session).cleanup_remote!
|
||||
end
|
||||
|
||||
def recording_ready?
|
||||
rec = Recording.find_by(stream_session: @session)
|
||||
rec&.status == "ready"
|
||||
end
|
||||
|
||||
def fail_recording!(recording, message)
|
||||
recording.update!(status: "failed", error_message: message)
|
||||
cleanup_mediamtx_path
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Recordings
|
||||
# Collega la live YouTube (broadcast_id) al VOD e aggiorna metadata recording.
|
||||
class VerifyYoutubeReplay
|
||||
class Error < StandardError; end
|
||||
|
||||
Result = Struct.new(:status, :message, keyword_init: true) do
|
||||
def ok?
|
||||
status == :ok
|
||||
end
|
||||
|
||||
def retriable?
|
||||
status == :pending
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(recording)
|
||||
@recording = recording
|
||||
end
|
||||
|
||||
def call
|
||||
unless @recording.temporary_storage?
|
||||
return Result.new(status: :skipped, message: "not_temporary")
|
||||
end
|
||||
|
||||
if @recording.youtube_verified_at.present? && @recording.youtube_video_id.present?
|
||||
return Result.new(status: :ok, message: "already_verified")
|
||||
end
|
||||
|
||||
session = @recording.stream_session
|
||||
broadcast_id = session&.youtube_broadcast_id
|
||||
if broadcast_id.blank?
|
||||
Rails.logger.warn(
|
||||
"[Recordings::VerifyYoutubeReplay] missing broadcast_id recording=#{@recording.id}"
|
||||
)
|
||||
return Result.new(status: :failed, message: "missing_broadcast_id")
|
||||
end
|
||||
|
||||
info = Youtube::VodStatus.new(@recording.team, channel: "team").fetch(broadcast_id)
|
||||
unless info.ready
|
||||
Rails.logger.info(
|
||||
"[Recordings::VerifyYoutubeReplay] not_ready recording=#{@recording.id} " \
|
||||
"video=#{broadcast_id} upload_status=#{info.upload_status.inspect}"
|
||||
)
|
||||
return Result.new(status: :pending, message: "vod_not_ready")
|
||||
end
|
||||
|
||||
apply_verified!(info)
|
||||
Rails.logger.info(
|
||||
"[Recordings::VerifyYoutubeReplay] ok recording=#{@recording.id} " \
|
||||
"youtube_video_id=#{info.video_id}"
|
||||
)
|
||||
Result.new(status: :ok, message: "verified")
|
||||
rescue Youtube::VodStatus::Error => e
|
||||
Rails.logger.warn(
|
||||
"[Recordings::VerifyYoutubeReplay] api_error recording=#{@recording.id}: #{e.message}"
|
||||
)
|
||||
Result.new(status: :pending, message: e.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_verified!(info)
|
||||
meta = @recording.metadata.is_a?(Hash) ? @recording.metadata.deep_dup : {}
|
||||
yt = meta.fetch("youtube", {}).merge(
|
||||
"broadcast_id" => @recording.stream_session.youtube_broadcast_id,
|
||||
"thumbnail_url" => info.thumbnail_url,
|
||||
"privacy_status" => info.privacy_status,
|
||||
"upload_status" => info.upload_status,
|
||||
"verified_via" => "live_broadcast"
|
||||
).compact
|
||||
|
||||
attrs = {
|
||||
youtube_video_id: info.video_id,
|
||||
youtube_verified_at: Time.current,
|
||||
youtube_published_at: @recording.youtube_published_at || Time.current,
|
||||
metadata: meta.merge("youtube" => yt)
|
||||
}
|
||||
attrs[:duration_secs] = info.duration_secs if info.duration_secs.to_i.positive?
|
||||
attrs[:title] = info.title if info.title.present? && @recording.title.blank?
|
||||
|
||||
@recording.update!(attrs)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -55,13 +55,15 @@ module Sessions
|
||||
)
|
||||
end
|
||||
|
||||
kick_autoscaler_if_soft_limit!
|
||||
|
||||
if session.platform == "youtube"
|
||||
YoutubeBroadcastSetupJob.perform_later(session.id, youtube_channel)
|
||||
end
|
||||
|
||||
session
|
||||
rescue Streams::NodeRegistry::NoCapacityError => e
|
||||
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
||||
raise_no_capacity!(e)
|
||||
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
||||
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
|
||||
rescue Mediamtx::Client::Error => e
|
||||
@@ -73,6 +75,28 @@ module Sessions
|
||||
|
||||
private
|
||||
|
||||
def raise_no_capacity!(error)
|
||||
if Streams::Autoscaler.enabled?
|
||||
Streams::AutoscalerJob.kick!
|
||||
raise Teams::EntitlementError.new(
|
||||
"Capacità streaming in espansione. Riprova tra poco.",
|
||||
code: "stream_capacity_scaling"
|
||||
)
|
||||
end
|
||||
|
||||
raise Teams::EntitlementError.new(error.message, code: "stream_capacity_exhausted")
|
||||
end
|
||||
|
||||
def kick_autoscaler_if_soft_limit!
|
||||
return unless Streams::Autoscaler.enabled?
|
||||
|
||||
free = Streams::Autoscaler.metrics[:free_slots].to_i
|
||||
return if free > Streams::Autoscaler.soft_free_slots
|
||||
|
||||
Streams::AutoscalerJob.kick!
|
||||
end
|
||||
|
||||
|
||||
def assert_youtube_channel!(youtube_channel)
|
||||
resolver = Youtube::CredentialResolver.new(@match.team, channel: youtube_channel)
|
||||
if resolver.resolve.blank?
|
||||
|
||||
@@ -12,7 +12,7 @@ module Sessions
|
||||
complete_youtube_broadcast! if @session.youtube_broadcast_id.present?
|
||||
|
||||
recording = Recordings::FinalizeSession.new(@session).call
|
||||
Recordings::UploadJob.perform_async(@session.id) if recording
|
||||
Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
|
||||
remove_mediamtx_paths!
|
||||
|
||||
log_event("ended")
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Youtube
|
||||
# Stato VOD dopo una live (broadcast_id ≈ video_id su YouTube).
|
||||
class VodStatus
|
||||
class Error < StandardError; end
|
||||
|
||||
Result = Struct.new(
|
||||
:ready,
|
||||
:video_id,
|
||||
:title,
|
||||
:duration_secs,
|
||||
:thumbnail_url,
|
||||
:privacy_status,
|
||||
:upload_status,
|
||||
keyword_init: true
|
||||
)
|
||||
|
||||
def initialize(team, channel: "team")
|
||||
@team = team
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def fetch(video_id)
|
||||
raise Error, "video_id mancante" if video_id.blank?
|
||||
|
||||
if mock_or_unconfigured?(video_id)
|
||||
return Result.new(
|
||||
ready: true,
|
||||
video_id: video_id,
|
||||
title: nil,
|
||||
duration_secs: nil,
|
||||
thumbnail_url: nil,
|
||||
privacy_status: "unlisted",
|
||||
upload_status: "processed"
|
||||
)
|
||||
end
|
||||
|
||||
client = authorized_client
|
||||
item = client.list_videos("snippet,contentDetails,status", id: video_id).items&.first
|
||||
return Result.new(ready: false, video_id: video_id) if item.blank?
|
||||
|
||||
upload_status = item.status&.upload_status.to_s
|
||||
ready = upload_status.in?(%w[processed uploaded]) ||
|
||||
(item.snippet.present? && upload_status != "deleted" && upload_status != "rejected" && upload_status != "failed")
|
||||
|
||||
Result.new(
|
||||
ready: ready,
|
||||
video_id: item.id,
|
||||
title: item.snippet&.title,
|
||||
duration_secs: parse_duration(item.content_details&.duration),
|
||||
thumbnail_url: item.snippet&.thumbnails&.high&.url || item.snippet&.thumbnails&.default&.url,
|
||||
privacy_status: item.status&.privacy_status,
|
||||
upload_status: upload_status
|
||||
)
|
||||
rescue Google::Apis::Error => e
|
||||
raise Error, e.message
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mock_or_unconfigured?(video_id)
|
||||
video_id.to_s.start_with?("mock_") ||
|
||||
ENV["YOUTUBE_CLIENT_ID"].blank? ||
|
||||
CredentialResolver.new(@team, channel: @channel).resolve.blank?
|
||||
end
|
||||
|
||||
def authorized_client
|
||||
credential = CredentialResolver.new(@team, channel: @channel).resolve
|
||||
raise Error, "Credenziali YouTube non disponibili" if credential.blank?
|
||||
|
||||
OauthRefresh.new(credential).apply!(Google::Apis::YoutubeV3::YouTubeService.new)
|
||||
end
|
||||
|
||||
def parse_duration(iso)
|
||||
return nil if iso.blank?
|
||||
|
||||
# PT1H2M3S
|
||||
match = iso.match(/\APT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?\z/)
|
||||
return nil unless match
|
||||
|
||||
match[1].to_i * 3600 + match[2].to_i * 60 + match[3].to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user