aspera-cli 4.13.0 → 4.15.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +81 -7
- data/CONTRIBUTING.md +22 -6
- data/README.md +2038 -1080
- data/bin/ascli +18 -9
- data/bin/asession +12 -14
- data/examples/dascli +1 -1
- data/examples/proxy.pac +1 -1
- data/examples/rubyc +24 -0
- data/lib/aspera/aoc.rb +219 -159
- data/lib/aspera/ascmd.rb +25 -14
- data/lib/aspera/cli/basic_auth_plugin.rb +12 -9
- data/lib/aspera/cli/error.rb +17 -0
- data/lib/aspera/cli/extended_value.rb +47 -12
- data/lib/aspera/cli/formatter.rb +260 -179
- data/lib/aspera/cli/hints.rb +80 -0
- data/lib/aspera/cli/main.rb +104 -156
- data/lib/aspera/cli/manager.rb +259 -209
- data/lib/aspera/cli/plugin.rb +123 -63
- data/lib/aspera/cli/plugins/alee.rb +2 -3
- data/lib/aspera/cli/plugins/aoc.rb +341 -261
- data/lib/aspera/cli/plugins/ats.rb +22 -21
- data/lib/aspera/cli/plugins/bss.rb +5 -5
- data/lib/aspera/cli/plugins/config.rb +578 -627
- data/lib/aspera/cli/plugins/console.rb +44 -6
- data/lib/aspera/cli/plugins/cos.rb +15 -17
- data/lib/aspera/cli/plugins/faspex.rb +114 -100
- data/lib/aspera/cli/plugins/faspex5.rb +411 -264
- data/lib/aspera/cli/plugins/node.rb +354 -259
- data/lib/aspera/cli/plugins/orchestrator.rb +61 -29
- data/lib/aspera/cli/plugins/preview.rb +82 -90
- data/lib/aspera/cli/plugins/server.rb +79 -32
- data/lib/aspera/cli/plugins/shares.rb +55 -42
- data/lib/aspera/cli/sync_actions.rb +68 -0
- data/lib/aspera/cli/transfer_agent.rb +66 -73
- data/lib/aspera/cli/transfer_progress.rb +74 -0
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/colors.rb +12 -8
- data/lib/aspera/command_line_builder.rb +14 -11
- data/lib/aspera/cos_node.rb +3 -2
- data/lib/aspera/data/6 +0 -0
- data/lib/aspera/environment.rb +24 -9
- data/lib/aspera/fasp/agent_aspera.rb +126 -0
- data/lib/aspera/fasp/agent_base.rb +31 -77
- data/lib/aspera/fasp/agent_connect.rb +25 -21
- data/lib/aspera/fasp/agent_direct.rb +89 -103
- data/lib/aspera/fasp/agent_httpgw.rb +231 -149
- data/lib/aspera/fasp/agent_node.rb +41 -34
- data/lib/aspera/fasp/agent_trsdk.rb +75 -32
- data/lib/aspera/fasp/error_info.rb +4 -2
- data/lib/aspera/fasp/faux_file.rb +52 -0
- data/lib/aspera/fasp/installation.rb +53 -195
- data/lib/aspera/fasp/management.rb +244 -0
- data/lib/aspera/fasp/parameters.rb +71 -37
- data/lib/aspera/fasp/parameters.yaml +76 -8
- data/lib/aspera/fasp/products.rb +162 -0
- data/lib/aspera/fasp/resume_policy.rb +3 -3
- data/lib/aspera/fasp/transfer_spec.rb +7 -6
- data/lib/aspera/fasp/uri.rb +26 -24
- data/lib/aspera/faspex_gw.rb +2 -2
- data/lib/aspera/faspex_postproc.rb +2 -2
- data/lib/aspera/hash_ext.rb +14 -4
- data/lib/aspera/json_rpc.rb +49 -0
- data/lib/aspera/keychain/macos_security.rb +13 -13
- data/lib/aspera/line_logger.rb +23 -0
- data/lib/aspera/log.rb +58 -16
- data/lib/aspera/node.rb +157 -92
- data/lib/aspera/oauth.rb +37 -19
- data/lib/aspera/open_application.rb +4 -4
- data/lib/aspera/persistency_action_once.rb +1 -1
- data/lib/aspera/persistency_folder.rb +2 -2
- data/lib/aspera/preview/file_types.rb +4 -2
- data/lib/aspera/preview/generator.rb +22 -35
- data/lib/aspera/preview/options.rb +2 -0
- data/lib/aspera/preview/terminal.rb +73 -16
- data/lib/aspera/preview/utils.rb +21 -28
- data/lib/aspera/proxy_auto_config.js +2 -2
- data/lib/aspera/rest.rb +136 -68
- data/lib/aspera/rest_call_error.rb +1 -1
- data/lib/aspera/rest_error_analyzer.rb +15 -14
- data/lib/aspera/rest_errors_aspera.rb +37 -34
- data/lib/aspera/secret_hider.rb +18 -15
- data/lib/aspera/ssh.rb +5 -2
- data/lib/aspera/sync.rb +127 -119
- data/lib/aspera/temp_file_manager.rb +10 -3
- data/lib/aspera/web_auth.rb +10 -7
- data/lib/aspera/web_server_simple.rb +9 -4
- data.tar.gz.sig +0 -0
- metadata +34 -17
- metadata.gz.sig +0 -0
- data/docs/test_env.conf +0 -186
- data/lib/aspera/cli/listener/line_dump.rb +0 -19
- data/lib/aspera/cli/listener/logger.rb +0 -22
- data/lib/aspera/cli/listener/progress.rb +0 -50
- data/lib/aspera/cli/listener/progress_multi.rb +0 -84
- data/lib/aspera/cli/plugins/sync.rb +0 -44
- data/lib/aspera/data/7 +0 -0
- data/lib/aspera/fasp/listener.rb +0 -13
data/lib/aspera/oauth.rb
CHANGED
@@ -24,18 +24,23 @@ module Aspera
|
|
24
24
|
# OAuth methods supported by default
|
25
25
|
STD_AUTH_TYPES = %i[web jwt].freeze
|
26
26
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
27
|
+
@@globals = { # rubocop:disable Style/ClassVars
|
28
|
+
# remove 5 minutes to account for time offset between client and server (TODO: configurable?)
|
29
|
+
jwt_accepted_offset_sec: 300,
|
30
|
+
# one hour validity (TODO: configurable?)
|
31
|
+
jwt_expiry_offset_sec: 3600,
|
32
|
+
# tokens older than 30 minutes will be discarded from cache
|
33
|
+
token_cache_expiry_sec: 1800,
|
34
|
+
# tokens valid for less than this duration will be regenerated
|
35
|
+
token_expiration_guard_sec: 120
|
36
|
+
}
|
37
|
+
|
35
38
|
# a prefix for persistency of tokens (simplify garbage collect)
|
36
39
|
PERSIST_CATEGORY_TOKEN = 'token'
|
40
|
+
# prefix for bearer token when in header
|
41
|
+
BEARER_PREFIX = 'Bearer '
|
37
42
|
|
38
|
-
private_constant :
|
43
|
+
private_constant :PERSIST_CATEGORY_TOKEN, :BEARER_PREFIX
|
39
44
|
|
40
45
|
# persistency manager
|
41
46
|
@persist = nil
|
@@ -45,10 +50,23 @@ module Aspera
|
|
45
50
|
@id_handlers = {}
|
46
51
|
|
47
52
|
class << self
|
53
|
+
def bearer_build(token)
|
54
|
+
return BEARER_PREFIX + token
|
55
|
+
end
|
56
|
+
|
57
|
+
def bearer_extract(token)
|
58
|
+
raise 'not a bearer token, wrong prefix' unless bearer?(token)
|
59
|
+
return token[BEARER_PREFIX.length..-1]
|
60
|
+
end
|
61
|
+
|
62
|
+
def bearer?(token)
|
63
|
+
return token.start_with?(BEARER_PREFIX)
|
64
|
+
end
|
65
|
+
|
48
66
|
def persist_mgr=(manager)
|
49
67
|
@persist = manager
|
50
68
|
# cleanup expired tokens
|
51
|
-
@persist.garbage_collect(PERSIST_CATEGORY_TOKEN,
|
69
|
+
@persist.garbage_collect(PERSIST_CATEGORY_TOKEN, @@globals[:token_cache_expiry_sec])
|
52
70
|
end
|
53
71
|
|
54
72
|
def persist_mgr
|
@@ -153,9 +171,9 @@ module Aspera
|
|
153
171
|
Log.log.info{"seconds=#{seconds_since_epoch}"}
|
154
172
|
raise 'missing JWT payload' unless oauth.specific_parameters[:payload].is_a?(Hash)
|
155
173
|
jwt_payload = {
|
156
|
-
exp: seconds_since_epoch +
|
157
|
-
nbf: seconds_since_epoch -
|
158
|
-
iat: seconds_since_epoch -
|
174
|
+
exp: seconds_since_epoch + @@globals[:jwt_expiry_offset_sec], # expiration time
|
175
|
+
nbf: seconds_since_epoch - @@globals[:jwt_accepted_offset_sec], # not before
|
176
|
+
iat: seconds_since_epoch - @@globals[:jwt_accepted_offset_sec] + 1, # issued at (we tell a little in the past so that server always accepts)
|
159
177
|
jti: SecureRandom.uuid # JWT id
|
160
178
|
}.merge(oauth.specific_parameters[:payload])
|
161
179
|
Log.log.debug{"JWT jwt_payload=[#{jwt_payload}]"}
|
@@ -192,7 +210,7 @@ module Aspera
|
|
192
210
|
# replace default values
|
193
211
|
@generic_parameters = DEFAULT_CREATE_PARAMS.deep_merge(a_params)
|
194
212
|
# legacy
|
195
|
-
@generic_parameters[:grant_method] ||= @generic_parameters.delete(:crtype) if @generic_parameters.key?(:crtype)
|
213
|
+
@generic_parameters[:grant_method] ||= @generic_parameters.delete(:crtype) if @generic_parameters.key?(:crtype) # cspell: disable-line
|
196
214
|
# check that type is known
|
197
215
|
self.class.token_creator(@generic_parameters[:grant_method])
|
198
216
|
# specific parameters for the creation type
|
@@ -213,8 +231,8 @@ module Aspera
|
|
213
231
|
@generic_parameters.delete(:base_url)
|
214
232
|
@generic_parameters.delete(:auth)
|
215
233
|
@generic_parameters.delete(@generic_parameters[:grant_method])
|
216
|
-
Log.dump(:generic_parameters, @generic_parameters)
|
217
|
-
Log.dump(:specific_parameters, @specific_parameters)
|
234
|
+
Log.log.debug{Log.dump(:generic_parameters, @generic_parameters)}
|
235
|
+
Log.log.debug{Log.dump(:specific_parameters, @specific_parameters)}
|
218
236
|
end
|
219
237
|
|
220
238
|
public
|
@@ -259,14 +277,14 @@ module Aspera
|
|
259
277
|
# `direct` agent is equipped with refresh code
|
260
278
|
if !use_refresh_token && !token_data.nil?
|
261
279
|
decoded_token = self.class.decode_token(token_data[@generic_parameters[:token_field]])
|
262
|
-
Log.dump('decoded_token', decoded_token) unless decoded_token.nil?
|
280
|
+
Log.log.debug{Log.dump('decoded_token', decoded_token)} unless decoded_token.nil?
|
263
281
|
if decoded_token.is_a?(Hash)
|
264
282
|
expires_at_sec =
|
265
283
|
if decoded_token['expires_at'].is_a?(String) then DateTime.parse(decoded_token['expires_at']).to_time
|
266
284
|
elsif decoded_token['exp'].is_a?(Integer) then Time.at(decoded_token['exp'])
|
267
285
|
end
|
268
286
|
# force refresh if we see a token too close from expiration
|
269
|
-
use_refresh_token = true if expires_at_sec.is_a?(Time) && (expires_at_sec - Time.now) <
|
287
|
+
use_refresh_token = true if expires_at_sec.is_a?(Time) && (expires_at_sec - Time.now) < @@globals[:token_expiration_guard_sec]
|
270
288
|
Log.log.debug{"Expiration: #{expires_at_sec} / #{use_refresh_token}"}
|
271
289
|
end
|
272
290
|
end
|
@@ -306,7 +324,7 @@ module Aspera
|
|
306
324
|
end # if ! in_cache
|
307
325
|
raise "API error: No such field in answer: #{@generic_parameters[:token_field]}" unless token_data.key?(@generic_parameters[:token_field])
|
308
326
|
# ok we shall have a token here
|
309
|
-
return
|
327
|
+
return self.class.bearer_build(token_data[@generic_parameters[:token_field]])
|
310
328
|
end
|
311
329
|
end # OAuth
|
312
330
|
end # Aspera
|
@@ -27,7 +27,7 @@ module Aspera
|
|
27
27
|
def uri_graphical(uri)
|
28
28
|
case Aspera::Environment.os
|
29
29
|
when Aspera::Environment::OS_X then return system('open', uri.to_s)
|
30
|
-
when Aspera::Environment::OS_WINDOWS then return system('start', 'explorer',
|
30
|
+
when Aspera::Environment::OS_WINDOWS then return system('start', 'explorer', %Q{"#{uri}"})
|
31
31
|
when Aspera::Environment::OS_LINUX then return system('xdg-open', uri.to_s)
|
32
32
|
else
|
33
33
|
raise "no graphical open method for #{Aspera::Environment.os}"
|
@@ -38,7 +38,7 @@ module Aspera
|
|
38
38
|
if ENV.key?('EDITOR')
|
39
39
|
system(ENV['EDITOR'], file_path.to_s)
|
40
40
|
elsif Aspera::Environment.os.eql?(Aspera::Environment::OS_WINDOWS)
|
41
|
-
system('notepad.exe',
|
41
|
+
system('notepad.exe', %Q{"#{file_path}"})
|
42
42
|
else
|
43
43
|
uri_graphical(file_path.to_s)
|
44
44
|
end
|
@@ -59,9 +59,9 @@ module Aspera
|
|
59
59
|
when :text
|
60
60
|
case the_url.to_s
|
61
61
|
when /^http/
|
62
|
-
puts "USER ACTION: please enter this url in a browser:\n
|
62
|
+
puts "USER ACTION: please enter this url in a browser:\n#{the_url.to_s.red}\n"
|
63
63
|
else
|
64
|
-
puts "USER ACTION: open this:\n
|
64
|
+
puts "USER ACTION: open this:\n#{the_url.to_s.red}\n"
|
65
65
|
end
|
66
66
|
else
|
67
67
|
raise StandardError, "unsupported url open method: #{@url_method}"
|
@@ -8,7 +8,7 @@ module Aspera
|
|
8
8
|
class PersistencyActionOnce
|
9
9
|
# @param :manager Mandatory Database
|
10
10
|
# @param :data Mandatory object to persist, must be same object from begin to end (assume array by default)
|
11
|
-
# @param :id
|
11
|
+
# @param :id Mandatory identifiers
|
12
12
|
# @param :delete Optional delete persistency condition
|
13
13
|
# @param :parse Optional parse method (default to JSON)
|
14
14
|
# @param :format Optional dump method (default to JSON)
|
@@ -37,7 +37,7 @@ module Aspera
|
|
37
37
|
raise 'value: only String supported' unless value.is_a?(String)
|
38
38
|
persist_filepath = id_to_filepath(object_id)
|
39
39
|
Log.log.debug{"persistency saving: #{persist_filepath}"}
|
40
|
-
|
40
|
+
FileUtils.rm_f(persist_filepath)
|
41
41
|
File.write(persist_filepath, value)
|
42
42
|
Environment.restrict_file_access(persist_filepath)
|
43
43
|
@cache[object_id] = value
|
@@ -46,7 +46,7 @@ module Aspera
|
|
46
46
|
def delete(object_id)
|
47
47
|
persist_filepath = id_to_filepath(object_id)
|
48
48
|
Log.log.debug{"persistency deleting: #{persist_filepath}"}
|
49
|
-
|
49
|
+
FileUtils.rm_f(persist_filepath)
|
50
50
|
@cache.delete(object_id)
|
51
51
|
end
|
52
52
|
|
@@ -281,7 +281,7 @@ module Aspera
|
|
281
281
|
|
282
282
|
# use mime magic to find mime type based on file content (magic numbers)
|
283
283
|
def mime_from_file(filepath)
|
284
|
-
# moved here, as mimemagic can cause installation issues
|
284
|
+
# moved here, as `mimemagic` can cause installation issues
|
285
285
|
require 'mimemagic'
|
286
286
|
require 'mimemagic/version'
|
287
287
|
require 'mimemagic/overlay' if MimeMagic::VERSION.start_with?('0.3.')
|
@@ -297,9 +297,10 @@ module Aspera
|
|
297
297
|
return detected_mime
|
298
298
|
end
|
299
299
|
|
300
|
-
# return file type, one of enum CONVERSION_TYPES
|
301
300
|
# @param filepath [String] full path to file
|
302
301
|
# @param mimetype [String] provided by node api
|
302
|
+
# @return file type, one of enum CONVERSION_TYPES
|
303
|
+
# @raise [RuntimeError] if no conversion type found
|
303
304
|
def conversion_type(filepath, mimetype)
|
304
305
|
Log.log.debug{"conversion_type(#{filepath},m=#{mimetype},t=#{@use_mimemagic})"}
|
305
306
|
# 1- get type from provided mime type, using local mapping
|
@@ -322,6 +323,7 @@ module Aspera
|
|
322
323
|
# 3- else, from extensions, using local mapping
|
323
324
|
extension = File.extname(filepath.downcase)[1..-1]
|
324
325
|
conversion_type = SUPPORTED_EXTENSIONS[extension] if conversion_type.nil?
|
326
|
+
raise "no conversion type found for extension #{extension}" if conversion_type.nil?
|
325
327
|
Log.log.debug{"conversion_type(#{extension}): #{conversion_type.class.name} [#{conversion_type}]"}
|
326
328
|
return conversion_type
|
327
329
|
end
|
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
# ffmpeg options:
|
4
4
|
# spellchecker:ignore pauseframes libx264 trunc bufsize muxer apng libmp3lame maxrate posterize movflags faststart
|
5
|
-
# spellchecker:ignore palettegen paletteuse pointsize bordercolor repage lanczos
|
5
|
+
# spellchecker:ignore palettegen paletteuse pointsize bordercolor repage lanczos unoconv optipng reencode conv transframes
|
6
6
|
|
7
7
|
require 'open3'
|
8
8
|
require 'aspera/preview/options'
|
@@ -19,6 +19,7 @@ module Aspera
|
|
19
19
|
FFMPEG_OPTIONS_LIST = %w[in out].freeze
|
20
20
|
|
21
21
|
# CLI needs to know conversion type to know if need skip it
|
22
|
+
# one of CONVERSION_TYPES
|
22
23
|
attr_reader :conversion_type
|
23
24
|
|
24
25
|
# @param src source file path
|
@@ -32,55 +33,40 @@ module Aspera
|
|
32
33
|
# -> preview_format is one of Generator::PREVIEW_FORMATS
|
33
34
|
# the conversion video->mp4 is implemented in methods: convert_video_to_mp4_using_<video_conversion>
|
34
35
|
# -> conversion method is one of Generator::VIDEO_CONVERSION_METHODS
|
35
|
-
def initialize(
|
36
|
-
@options = options
|
36
|
+
def initialize(src, dst, options, main_temp_dir, api_mime_type)
|
37
37
|
@source_file_path = src
|
38
38
|
@destination_file_path = dst
|
39
|
+
@options = options
|
39
40
|
@temp_folder = File.join(main_temp_dir, @source_file_path.split('/').last.gsub(/\s/, '_').gsub(/\W/, ''))
|
40
41
|
# extract preview format from extension of target file
|
41
|
-
@
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
# combination of: conversion type and output format (and video_conversion for video)
|
47
|
-
def processing_method_symb
|
48
|
-
name = "convert_#{@conversion_type}_to_#{@preview_format_symb}"
|
49
|
-
if @conversion_type.eql?(:video)
|
50
|
-
case @preview_format_symb
|
42
|
+
@preview_format_sym = File.extname(@destination_file_path).gsub(/^\./, '').to_sym
|
43
|
+
conversion_type = FileTypes.instance.conversion_type(@source_file_path, api_mime_type)
|
44
|
+
@processing_method = "convert_#{conversion_type}_to_#{@preview_format_sym}"
|
45
|
+
if conversion_type.eql?(:video)
|
46
|
+
case @preview_format_sym
|
51
47
|
when :mp4
|
52
|
-
|
48
|
+
@processing_method = "#{@processing_method}_using_#{@options.video_conversion}"
|
53
49
|
when :png
|
54
|
-
|
50
|
+
@processing_method = "#{@processing_method}_using_#{@options.video_png_conv}"
|
55
51
|
end
|
56
52
|
end
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
# @return true if conversion is supported.
|
62
|
-
# for instance, plaintext to mp4 is not supported
|
63
|
-
def supported?
|
64
|
-
return false if @conversion_type.nil?
|
65
|
-
return respond_to?(processing_method_symb, true)
|
53
|
+
@processing_method = @processing_method.to_sym
|
54
|
+
Log.log.debug{"method: #{@processing_method}"}
|
55
|
+
raise "no processing know for #{conversion_type} -> #{@preview_format_sym}" unless respond_to?(@processing_method, true)
|
66
56
|
end
|
67
57
|
|
68
58
|
# create preview as specified in constructor
|
69
59
|
def generate
|
70
|
-
|
71
|
-
method_symb = processing_method_symb
|
72
|
-
Log.log.info{"#{@source_file_path}->#{@destination_file_path} (#{method_symb})"}
|
60
|
+
Log.log.info{"#{@source_file_path}->#{@destination_file_path} (#{@processing_method})"}
|
73
61
|
begin
|
74
|
-
send(
|
62
|
+
send(@processing_method)
|
75
63
|
# check that generated size does not exceed maximum
|
76
64
|
result_size = File.size(@destination_file_path)
|
77
|
-
if result_size > @options.max_size
|
78
|
-
Log.log.warn{"preview size exceeds maximum allowed #{result_size} > #{@options.max_size}"}
|
79
|
-
end
|
65
|
+
Log.log.warn{"preview size exceeds maximum allowed #{result_size} > #{@options.max_size}"} if result_size > @options.max_size
|
80
66
|
rescue StandardError => e
|
81
67
|
Log.log.error{"Ignoring: #{e.message}"}
|
82
68
|
Log.log.debug(e.backtrace.join("\n").red)
|
83
|
-
FileUtils.cp(File.expand_path(@
|
69
|
+
FileUtils.cp(File.expand_path(@preview_format_sym.eql?(:mp4) ? 'video_error.png' : 'image_error.png', File.dirname(__FILE__)), @destination_file_path)
|
84
70
|
ensure
|
85
71
|
FileUtils.rm_rf(@temp_folder)
|
86
72
|
end
|
@@ -134,8 +120,8 @@ module Aspera
|
|
134
120
|
# generate n clips starting at offset
|
135
121
|
def convert_video_to_mp4_using_clips
|
136
122
|
p_duration = Utils.video_get_duration(@source_file_path)
|
137
|
-
|
138
|
-
File.open(
|
123
|
+
file_list_file = File.join(this_tmpdir, 'clip_files.txt')
|
124
|
+
File.open(file_list_file, 'w+') do |f|
|
139
125
|
1.upto(@options.clips_count.to_i) do |i|
|
140
126
|
offset_seconds = get_offset(p_duration, @options.video_start_sec.to_i, @options.clips_count.to_i, i)
|
141
127
|
tmp_file_name = format('clip%04d.mp4', i)
|
@@ -153,10 +139,11 @@ module Aspera
|
|
153
139
|
end
|
154
140
|
# concat clips
|
155
141
|
Utils.ffmpeg(
|
156
|
-
in_f:
|
142
|
+
in_f: file_list_file,
|
157
143
|
in_p: ['-f', 'concat'],
|
158
144
|
out_f: @destination_file_path,
|
159
145
|
out_p: ['-codec', 'copy'])
|
146
|
+
File.delete(file_list_file)
|
160
147
|
end
|
161
148
|
|
162
149
|
# do a simple re-encoding
|
@@ -1,34 +1,91 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# cspell:words Magick MAGICKCORE ITERM mintty winsize termcap
|
4
|
+
|
3
5
|
require 'rmagick' # https://rmagick.github.io/index.html
|
4
6
|
require 'rainbow'
|
5
7
|
require 'io/console'
|
6
8
|
module Aspera
|
7
9
|
module Preview
|
8
|
-
#
|
10
|
+
# Display a picture in the terminal, either using coloured characters or iTerm2
|
9
11
|
class Terminal
|
12
|
+
# Rainbow only supports 8-bit colors
|
13
|
+
# quantum depth is 8 or 16, see: `convert xc: -format "%q" info:`
|
14
|
+
SHIFT_FOR_8_BIT = Magick::MAGICKCORE_QUANTUM_DEPTH - 8
|
15
|
+
# env vars to detect terminal type
|
16
|
+
TERM_ENV_VARS = %w[TERM_PROGRAM LC_TERMINAL].freeze
|
17
|
+
# terminal names that support iTerm2 image display
|
18
|
+
ITERM_NAMES = %w[iTerm WezTerm mintty].freeze
|
19
|
+
# TODO: retrieve terminal font ratio using some termcap ?
|
20
|
+
# ratio = font height / font width
|
21
|
+
DEFAULT_FONT_RATIO = 32.0 / 14.0
|
22
|
+
private_constant :SHIFT_FOR_8_BIT, :TERM_ENV_VARS, :ITERM_NAMES, :DEFAULT_FONT_RATIO
|
10
23
|
class << self
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
24
|
+
# @return [String] the image as text, or the iTerm2 escape sequence
|
25
|
+
# @param blob [String] the image as a binary string
|
26
|
+
# @param reserve [Integer] number of lines to reserve for other text than the image
|
27
|
+
# @param text [Boolean] true to display the image as text, false to use iTerm2
|
28
|
+
# @param double [Boolean] true to use colors on half lines, false to use colors on full lines
|
29
|
+
# @param font_ratio [Float] ratio = font height / font width
|
30
|
+
def build(blob, reserve: 3, text: false, double: true, font_ratio: DEFAULT_FONT_RATIO)
|
31
|
+
return iterm_display_image(blob) if iterm_supported? && !text
|
16
32
|
image = Magick::ImageList.new.from_blob(blob)
|
17
|
-
|
18
|
-
|
19
|
-
|
33
|
+
(term_rows, term_columns) = IO.console.winsize
|
34
|
+
term_rows -= reserve
|
35
|
+
# compute scaling to fit terminal
|
36
|
+
fit_term_ratio = [term_rows / image.rows.to_f, term_columns / image.columns.to_f].min
|
37
|
+
height_ratio = double ? 2.0 : 1.0
|
38
|
+
image = image.scale((image.columns * fit_term_ratio * font_ratio).to_i, (image.rows * fit_term_ratio * height_ratio).to_i)
|
39
|
+
# get all pixel colors, adjusted for Rainbow
|
40
|
+
pixel_colors = []
|
20
41
|
image.each_pixel do |pixel, col, row|
|
21
|
-
|
22
|
-
pixel_rgb =
|
23
|
-
|
24
|
-
|
25
|
-
|
42
|
+
pixel_rgb = [pixel.red, pixel.green, pixel.blue]
|
43
|
+
pixel_rgb = pixel_rgb.map { |color| color >> SHIFT_FOR_8_BIT } unless SHIFT_FOR_8_BIT.eql?(0)
|
44
|
+
# init 2-dim array
|
45
|
+
pixel_colors[row] ||= []
|
46
|
+
pixel_colors[row][col] = pixel_rgb
|
47
|
+
end
|
48
|
+
# now generate text
|
49
|
+
text_pixels = []
|
50
|
+
pixel_colors.each_with_index do |row_data, row|
|
51
|
+
next if double && (row.odd? || row.eql?(pixel_colors.length - 1))
|
52
|
+
row_data.each_with_index do |pixel_rgb, col|
|
53
|
+
text_pixels.push("\n") if col.eql?(0) && !row.eql?(0)
|
54
|
+
if double
|
55
|
+
text_pixels.push(Rainbow('▄').background(pixel_rgb).foreground(pixel_colors[row + 1][col]))
|
56
|
+
else
|
57
|
+
text_pixels.push(Rainbow(' ').background(pixel_rgb))
|
58
|
+
end
|
26
59
|
end
|
27
|
-
text_pixels.push(Rainbow(' ').background(pixel_rgb))
|
28
60
|
end
|
29
61
|
return text_pixels.join
|
30
62
|
end
|
31
|
-
|
63
|
+
|
64
|
+
# display image in iTerm2
|
65
|
+
# https://iterm2.com/documentation-images.html
|
66
|
+
def iterm_display_image(blob)
|
67
|
+
# image = Magick::ImageList.new.from_blob(blob)
|
68
|
+
# parameters for iTerm2 image display
|
69
|
+
arguments = {
|
70
|
+
inline: 1,
|
71
|
+
preserveAspectRatio: 1,
|
72
|
+
size: blob.length
|
73
|
+
# width: image.columns,
|
74
|
+
# height: image.rows
|
75
|
+
}.map { |k, v| "#{k}=#{v}" }.join(';')
|
76
|
+
# \a is BEL, \e is ESC : https://github.com/ruby/ruby/blob/master/doc/syntax/literals.rdoc#label-Strings
|
77
|
+
# escape sequence for iTerm2 image display
|
78
|
+
return "\e]1337;File=#{arguments}:#{Base64.encode64(blob)}\a"
|
79
|
+
end
|
80
|
+
|
81
|
+
# @return [Boolean] true if the terminal supports iTerm2 image display
|
82
|
+
def iterm_supported?
|
83
|
+
TERM_ENV_VARS.each do |env_var|
|
84
|
+
return true if ITERM_NAMES.any? { |term| ENV[env_var]&.include?(term) }
|
85
|
+
end
|
86
|
+
false
|
87
|
+
end
|
88
|
+
end # class << self
|
32
89
|
end # class Terminal
|
33
90
|
end # module Preview
|
34
91
|
end # module Aspera
|
data/lib/aspera/preview/utils.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
# cspell:ignore ffprobe optipng unoconv
|
3
4
|
require 'English'
|
4
5
|
require 'tmpdir'
|
5
6
|
require 'fileutils'
|
@@ -11,18 +12,17 @@ module Aspera
|
|
11
12
|
class Utils
|
12
13
|
# from bash manual: meta-character need to be escaped
|
13
14
|
BASH_SPECIAL_CHARACTERS = "|&;()<> \t#\n"
|
14
|
-
# shell exit code when command is not found
|
15
|
-
BASH_EXIT_NOT_FOUND = 127
|
16
15
|
# external binaries used
|
17
16
|
EXTERNAL_TOOLS = %i[ffmpeg ffprobe convert composite optipng unoconv].freeze
|
18
17
|
TEMP_FORMAT = 'img%04d.jpg'
|
19
|
-
private_constant :BASH_SPECIAL_CHARACTERS, :
|
18
|
+
private_constant :BASH_SPECIAL_CHARACTERS, :EXTERNAL_TOOLS, :TEMP_FORMAT
|
20
19
|
|
21
20
|
class << self
|
22
|
-
# returns string with single quotes suitable for bash if there is any bash
|
21
|
+
# returns string with single quotes suitable for bash if there is any bash meta-character
|
23
22
|
def shell_quote(argument)
|
24
23
|
return argument unless argument.chars.any?{|c|BASH_SPECIAL_CHARACTERS.include?(c)}
|
25
|
-
|
24
|
+
# surround with single quotes, and escape single quotes
|
25
|
+
return %Q{'#{argument.gsub("'"){|_s| %q{'"'"'}}}'}
|
26
26
|
end
|
27
27
|
|
28
28
|
# check that external tools can be executed
|
@@ -30,38 +30,31 @@ module Aspera
|
|
30
30
|
tools_to_check = EXTERNAL_TOOLS.dup
|
31
31
|
tools_to_check.delete(:unoconv) if skip_types.include?(:office)
|
32
32
|
# Check for binaries
|
33
|
-
tools_to_check.each do |
|
34
|
-
external_command(
|
33
|
+
tools_to_check.each do |command_sym|
|
34
|
+
external_command(command_sym, ['-h'], log_error: false)
|
35
|
+
rescue Errno::ENOENT => e
|
36
|
+
raise "missing #{command_sym} binary: #{e}"
|
37
|
+
rescue
|
38
|
+
nil
|
35
39
|
end
|
36
40
|
end
|
37
41
|
|
38
42
|
# execute external command
|
39
43
|
# one could use "system", but we would need to redirect stdout/err
|
40
44
|
# @return true if su
|
41
|
-
def external_command(
|
42
|
-
raise "unexpected command #{
|
45
|
+
def external_command(command_sym, command_args, log_error: true)
|
46
|
+
raise "unexpected command #{command_sym}" unless EXTERNAL_TOOLS.include?(command_sym)
|
43
47
|
# build command line, and quote special characters
|
44
|
-
|
45
|
-
Log.log.debug{"cmd=#{
|
46
|
-
|
47
|
-
if
|
48
|
-
|
49
|
-
else
|
50
|
-
stderr = '<merged with stdout>'
|
51
|
-
stdout = %x(#{command} 2>&1)
|
52
|
-
exit_status = $CHILD_STATUS
|
53
|
-
end
|
54
|
-
if BASH_EXIT_NOT_FOUND.eql?(exit_status)
|
55
|
-
raise "Error: #{command_symb} is not in the PATH"
|
56
|
-
end
|
57
|
-
unless exit_status.success?
|
58
|
-
Log.log.error{"commandline: #{command}"}
|
59
|
-
Log.log.error{"Error code: #{exit_status}"}
|
48
|
+
command_line = command_args.clone.unshift(command_sym).map{|i| shell_quote(i.to_s)}.join(' ')
|
49
|
+
Log.log.debug{"cmd=#{command_line}".blue}
|
50
|
+
stdout, stderr, status = Open3.capture3(command_line)
|
51
|
+
if log_error && !status.success?
|
52
|
+
Log.log.error{"status: #{status}"}
|
60
53
|
Log.log.error{"stdout: #{stdout}"}
|
61
54
|
Log.log.error{"stderr: #{stderr}"}
|
62
|
-
raise "#{
|
55
|
+
raise "#{command_sym} error #{status}"
|
63
56
|
end
|
64
|
-
return {status:
|
57
|
+
return {status: status, stdout: stdout}
|
65
58
|
end
|
66
59
|
|
67
60
|
def ffmpeg(a)
|
@@ -82,7 +75,7 @@ module Aspera
|
|
82
75
|
result = external_command(:ffprobe, [
|
83
76
|
'-loglevel', 'error',
|
84
77
|
'-show_entries', 'format=duration',
|
85
|
-
'-print_format', 'default=noprint_wrappers=1:nokey=1',
|
78
|
+
'-print_format', 'default=noprint_wrappers=1:nokey=1', # cspell:disable-line
|
86
79
|
input_file])
|
87
80
|
return result[:stdout].to_f
|
88
81
|
end
|
@@ -70,7 +70,7 @@ function shExpMatch(str, shell_expr) {
|
|
70
70
|
}
|
71
71
|
function weekdayRange(wd1, wd2, gmt) {
|
72
72
|
var today = new Date();
|
73
|
-
var days = 'SUNMONTUEWEDTHUFRISAT';
|
73
|
+
var days = 'SUNMONTUEWEDTHUFRISAT'; // cspell: disable-line
|
74
74
|
wd1 = wd1.toUpperCase();
|
75
75
|
if (wd2 == undefined)
|
76
76
|
wd2 = wd1;
|
@@ -138,7 +138,7 @@ function dateRange() {
|
|
138
138
|
else
|
139
139
|
return false;
|
140
140
|
} else if (typeof arg == 'string') {
|
141
|
-
var months = 'JANFEBMARAPRMAYJUNJULAUGSEPOCTNOVDEC';
|
141
|
+
var months = 'JANFEBMARAPRMAYJUNJULAUGSEPOCTNOVDEC'; // cspell: disable-line
|
142
142
|
arg = arg.toUpperCase();
|
143
143
|
arg = months.indexOf(arg);
|
144
144
|
if (arg == -1)
|