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:
2026-06-03 07:53:11 +02:00
parent a87cda156b
commit 1f273f849d
87 changed files with 2952 additions and 195 deletions

View File

@@ -19,8 +19,10 @@ module Admin
teams_count: Team.count,
users_count: User.count,
matches_count: Match.count,
recordings_count: Recording.count,
recordings_ready: Recording.where(status: "ready").count
recordings_count: Recording.not_deleted.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

View File

@@ -25,6 +25,10 @@ module Mediamtx
recordPath: "/recordings/%path/%Y-%m-%d_%H-%M-%S",
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?
body[:runOnReady] = run_on_ready_script(session)
body[:runOnReadyRestart] = true

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View 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

View 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

View 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

View 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 lupload" 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

View 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

View 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

View 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

View File

@@ -9,8 +9,14 @@ module Sessions
Sessions::RegiaAccess.revoke!(@session)
@session.end_stream!
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")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session

View File

@@ -19,7 +19,7 @@ module Youtube
when :platform
PlatformCredential.configured? ? PlatformCredential.new : nil
when :team
@team.youtube_credential
@team.club.youtube_credential
end
end
@@ -32,7 +32,7 @@ module Youtube
end
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
def effective_channel

View File

@@ -3,12 +3,18 @@ module Youtube
PURPOSE = :youtube_oauth_connect
class << self
def for_team(team_id:, user_id:, return_app: false)
payload = [team_id.to_s, user_id.to_s]
def for_club(club_id:, user_id:, return_app: false)
payload = ["club", club_id.to_s, user_id.to_s]
payload << "app" if return_app
verifier.generate(payload, expires_in: 1.hour)
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:)
verifier.generate(["platform", admin_id.to_s], expires_in: 1.hour)
end
@@ -19,10 +25,21 @@ module Youtube
when Array
if payload.first == "platform"
{ kind: :platform, admin_id: payload[1] }
elsif payload.size >= 2
elsif payload.first == "club" && payload.size >= 3
{
kind: :team,
team_id: payload[0],
kind: :club,
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],
return_app: payload[2] == "app"
}

View File

@@ -15,7 +15,7 @@ module Youtube
when "matchlivetv_light"
PlatformCredential.configured?
when "team"
@team.youtube_credential.present? || PlatformCredential.configured?
@team.club.youtube_credential.present? || PlatformCredential.configured?
else
false
end
@@ -31,7 +31,7 @@ module Youtube
if @mode == "matchlivetv_light"
PlatformCredential.configured?
elsif @mode == "team"
@team.youtube_credential.blank? && PlatformCredential.configured?
@team.club.youtube_credential.blank? && PlatformCredential.configured?
else
false
end
@@ -41,12 +41,12 @@ module Youtube
if uses_platform_channel?
PLATFORM_LABEL
else
@team.youtube_credential&.channel_title
@team.club.youtube_credential&.channel_title
end
end
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

View 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