mm_tool 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,322 @@
1
+ module MmTool
2
+
3
+ #=============================================================================
4
+ # Implement the command line interface for MmTool::ApplicationMain
5
+ #=============================================================================
6
+ class MmToolCli
7
+
8
+ require 'tty-command'
9
+ require 'tty-which'
10
+
11
+ #------------------------------------------------------------
12
+ # Initialize
13
+ #------------------------------------------------------------
14
+ def initialize(app_instance = MmTool::ApplicationMain.shared_application)
15
+ @application = app_instance
16
+ @defaults = MmUserDefaults.shared_user_defaults
17
+ @defaults.register_defaults(with_hash: USER_DEFAULTS)
18
+ end
19
+
20
+ #------------------------------------------------------------
21
+ # Print Help
22
+ #------------------------------------------------------------
23
+ #noinspection RubyResolve
24
+ def print_help
25
+ # Amount of hanging indent is dependent on the space arguments take up.
26
+ hang = @defaults.example_args_by_length[-1].length + 4
27
+
28
+ header = <<~HEREDOC
29
+ #{C.bold('Usage:')} #{File.basename($0)} [options...] <directory|file> [options...] <directory|file> ...
30
+
31
+ Performs media analysis and reports on files that don't meet quality requirements and/or files that
32
+ have containers and/or streams of undesired types. Files with undesired containers and/or streams
33
+ can optionally be transcoded into a new file.
34
+
35
+ You must specify a file or a directory. Specifying a directory implies a recursive search for files
36
+ matching the #{C.bold('--container-extension')} option.
37
+
38
+ Options and files are processed as they occur, and remain in effect for subsequent input files until
39
+ encountered again with a different value. Boolean flags shown below modify default behavior. They
40
+ can be undone for subsequent input files with the capitalized version of the short flag, or by adding
41
+ or deleting #{C.bold('no-')} for the verbose argument form.
42
+
43
+ You can also set default options in #{PATH_USER_DEFAULTS}.
44
+ HEREDOC
45
+
46
+ puts OutputHelper.hanging_string(string: header, hang: 3, margin: OutputHelper.console_width)
47
+
48
+ @defaults.arguments_help_by_group.each_pair do |group, arguments|
49
+ puts group
50
+ arguments.each do |argument|
51
+ puts OutputHelper.hanging_string(string: argument, hang: hang, margin: OutputHelper.console_width) + "\n"
52
+ end
53
+ end
54
+ end
55
+
56
+ #------------------------------------------------------------
57
+ # Validate pre-requisites.
58
+ #------------------------------------------------------------
59
+ #noinspection RubyResolve
60
+ def validate_prerequisites
61
+ commands = %w(ffmpeg)
62
+ codecs = %w(libx264 libx265 libfdk_aac)
63
+ task = TTY::Command.new(printer: :null)
64
+ success = true
65
+
66
+ # We'll check everything in items before failing, so that we can provide
67
+ # a comprehensive list to the user. No one wants to see what's missing
68
+ # on-by-one.
69
+ commands.each do |command|
70
+ unless TTY::Which.exist?(command)
71
+ OutputHelper.print_error("Error: #{C.bold(command)} is not installed (or not in your #{C.bold('$PATH')}).")
72
+ success = false
73
+ end
74
+ end
75
+ exit 1 unless success
76
+
77
+ # Now we'll make sure that all of the codecs that we're interested in
78
+ # are installed as part of ffmpeg. This is necessary because not every
79
+ # binary distribution supports non-free.
80
+
81
+ # Again, we'll check them all before failing in order to list everything.
82
+ codecs.each do |codec|
83
+ result = task.run!("ffprobe -v quiet -codecs | grep #{codec}")
84
+ OutputHelper.print_error("Error: ffmpeg was built without support for the #{C.bold(codec)} codec, which is required.") if result.failure?
85
+ success = success && result.success?
86
+ end
87
+ exit 1 unless success
88
+ end
89
+
90
+ #------------------------------------------------------------
91
+ # Run the CLI.
92
+ #------------------------------------------------------------
93
+ #noinspection RubyResolve
94
+ def run(args)
95
+
96
+ path = nil
97
+ while args.count > 0 do
98
+
99
+ # Convert single hyphen arguments to one or more multi-hyphen
100
+ # arguments. Doing this as an extra step eliminates redundancy,
101
+ # but also allows -abc in place of -a -b -c.
102
+ if args[0] =~ /^-[A-Za-z]+$/
103
+
104
+ args[0][1..-1].reverse.each_char do |char|
105
+
106
+ case char
107
+
108
+ when 'h'
109
+ args.insert(1, "--help")
110
+ when 'i'
111
+ args.insert(1, "--no-info-header")
112
+ when 'I'
113
+ args.insert(1, '--info-header')
114
+ when 't'
115
+ args.insert(1, "--transcode")
116
+ when 'T'
117
+ args.insert(1, "--no-transcode")
118
+ when 'u'
119
+ args.insert(1, "--no-fix-undefined-language")
120
+ when 'U'
121
+ args.insert(1, "--fix-undefined-language")
122
+ when 'p'
123
+ args.insert(1, "--dump")
124
+ when 'P'
125
+ args.insert(1, "--no-dump")
126
+ else
127
+ OutputHelper.print_error_and_exit("Error: option #{C.bold(args[0])} was specified, but I don't know what that means.")
128
+ end
129
+
130
+ end
131
+
132
+ args.shift
133
+ next
134
+ end
135
+
136
+ # The main loop processes options, commands, and files in first-in,
137
+ # first out order, which is the normal Unix way compared to how
138
+ # Ruby scripts try to handle things.
139
+ case args[0]
140
+
141
+ #-----------------------
142
+ # Main Options
143
+ #-----------------------
144
+
145
+ when '--help'
146
+ self.print_help
147
+ exit 0
148
+
149
+ when '--containers'
150
+ @defaults[:container_files] = validate_arg_value(args[0], args[1]).split(',')
151
+ args.shift
152
+
153
+ when '--scan'
154
+ value = validate_arg_value(args[0], args[1]).downcase
155
+ unless %w(normal all flagged quality force).include?(value)
156
+ OutputHelper.print_error("Note: value #{C.bold(value)} doesn't make sense; assuming #{C.bold('normal')}.")
157
+ value = 'normal'
158
+ end
159
+ @defaults[:scan_type] = value
160
+ args.shift
161
+
162
+ when '--ignore-titles'
163
+ @defaults[:ignore_titles] = true
164
+
165
+ when '--no-ignore-titles'
166
+ @defaults[:ignore_titles] = false
167
+
168
+ when '--no-info-header'
169
+ @defaults[:info_header] = false
170
+
171
+ when '--info-header'
172
+ @defaults[:info_header] = true
173
+
174
+ when '--version'
175
+ puts "#{File.basename $0}, version #{MmTool::VERSION}"
176
+
177
+ when '--'
178
+ break
179
+
180
+ #-----------------------
181
+ # Command-like Options
182
+ #-----------------------
183
+
184
+ when '--transcode'
185
+ @defaults[:ignore_files] = false
186
+ @defaults[:unignore_files] = false
187
+ @defaults[:transcode] = true
188
+
189
+ when '--no-transcode'
190
+ @defaults[:transcode] = false
191
+ @defaults.tempfile = nil
192
+
193
+ when '--ignore-files'
194
+ @defaults[:ignore_files] = true
195
+
196
+ when '--no-ignore-files'
197
+ @defaults[:ignore_files] = false
198
+
199
+ when '--unignore-files'
200
+ @defaults[:unignore_files] = true
201
+
202
+ when '--no-unignore-files'
203
+ @defaults[:unignore_files] = false
204
+
205
+
206
+ #-----------------------
207
+ # Media
208
+ #-----------------------
209
+
210
+ when '--containers-preferred'
211
+ @defaults[:containers_preferred] = validate_arg_value(args[0], args[1]).split(',')
212
+ args.shift
213
+
214
+ when '--codecs-audio-preferred'
215
+ @defaults[:codec_audio_preferred] = validate_arg_value(args[0], args[1]).split(',')
216
+ args.shift
217
+
218
+ when '--codecs-video-preferred'
219
+ @defaults[:codec_video_preferred] = validate_arg_value(args[0], args[1]).split(',')
220
+ args.shift
221
+
222
+ when '--codecs-subs-preferred'
223
+ @defaults[:codec_subs_preferred] = validate_arg_value(args[0], args[1]).split(',')
224
+ args.shift
225
+
226
+ when '--keep-langs-audio'
227
+ @defaults[:keep_langs_audio] = validate_arg_value(args[0], args[1]).split(',')
228
+ args.shift
229
+
230
+ when '--keep_langs_video'
231
+ @defaults[:keep_langs_video] = validate_arg_value(args[0], args[1]).split(',')
232
+ args.shift
233
+
234
+ when '--keep-langs-subs'
235
+ @defaults[:keep_langs_subs] = validate_arg_value(args[0], args[1]).split(',')
236
+ args.shift
237
+
238
+ when '--no-use-external-subs'
239
+ @defaults[:use_external_subs] = false
240
+
241
+ when '--use-external-subs'
242
+ @defaults[:use_external_subs] = true
243
+
244
+ #-----------------------
245
+ # Transcoding
246
+ #-----------------------
247
+
248
+ when '--no-drop-subs'
249
+ @defaults[:drop_subs] = false
250
+
251
+ when '--suffix'
252
+ @defaults[:suffix] = validate_arg_value(args[0], args[1])
253
+
254
+ when '--undefined-language'
255
+ @defaults[:suffix] = validate_arg_value(args[0], args[1])
256
+
257
+ when '--no-fix-undefined-language'
258
+ @defaults[:fix_undefined_language] = false
259
+
260
+ when '--fix-undefined-language'
261
+ @defaults[:fix_undefined_language] = true
262
+
263
+ #-----------------------
264
+ # Quality
265
+ #-----------------------
266
+
267
+ when '--min-width'
268
+ @defaults[:min_width] = validate_arg_value(args[0], args[1])
269
+
270
+ when '--min-channels'
271
+ @defaults[:min_channels] = validate_arg_value(args[0], args[1])
272
+
273
+ #-----------------------
274
+ # Other
275
+ #-----------------------
276
+
277
+ else
278
+
279
+ # An unknown parameter was encountered, so let's stop everything.
280
+ if args[0] =~ /^--.*$/
281
+ OutputHelper.print_error_and_exit("Error: option #{C.bold(args[0])} was specified, but I don't know what that means.")
282
+ end
283
+
284
+ # Otherwise, check for existence of the path, and warn or proceed.
285
+ path = File.expand_path(args[0])
286
+ if File.exist?(path)
287
+ @application.run(path)
288
+ else
289
+ OutputHelper.print_error("Note: skipping #{C.bold(path)}, which seems not to exist.")
290
+ end
291
+
292
+ end # case
293
+
294
+ args.shift
295
+
296
+ end # while
297
+
298
+ # If path has never been set, then the user didn't specify anything to check,
299
+ # which is likely to be a mistake.
300
+ unless path
301
+ OutputHelper.print_error_and_exit("You did not specify any input file(s) or directory(s). Use #{C.bold(File.basename($0))} for help.")
302
+ end
303
+
304
+ end
305
+
306
+ #------------------------------------------------------------
307
+ # Perform a really simple validation of the given value for
308
+ # the given argument, returning the value if successful.
309
+ # ------------------------------------------------------------
310
+ #noinspection RubyResolve
311
+ def validate_arg_value(arg, value)
312
+ if !value
313
+ OutputHelper.print_error_and_exit("Error: option #{C.bold(arg)} was specified, but no value was given.")
314
+ elsif value =~ /^-.*$/
315
+ OutputHelper.print_error_and_exit("Error: option #{C.bold(arg)} was specified, but the value #{C.bold(value)} looks like another option argument.")
316
+ end
317
+ value
318
+ end
319
+
320
+ end # class
321
+
322
+ end # module
@@ -0,0 +1,290 @@
1
+ module MmTool
2
+
3
+ #=============================================================================
4
+ # A single user default.
5
+ #=============================================================================
6
+ class MmUserDefault
7
+
8
+ #------------------------------------------------------------
9
+ # Attributes
10
+ #------------------------------------------------------------
11
+ attr_reader :name
12
+ attr_reader :default
13
+ attr_reader :arg_short
14
+ attr_reader :arg_long
15
+ attr_reader :arg_format
16
+ attr_reader :item_label
17
+ attr_reader :help_group
18
+ attr_reader :help_desc
19
+
20
+ #------------------------------------------------------------
21
+ # Initialize
22
+ #------------------------------------------------------------
23
+ def initialize(key:, value:)
24
+ @name = key
25
+ @value = value[:value]
26
+ @default = value[:default]
27
+ @arg_short = value[:arg_short]
28
+ @arg_long = value[:arg_long]
29
+ @arg_format = value[:arg_format]
30
+ @item_label = value[:item_label]
31
+ @help_group = value[:help_group]
32
+ @help_desc = value[:help_desc]
33
+ @value_set = false
34
+ end
35
+
36
+ #------------------------------------------------------------
37
+ # Value attribute accessors.
38
+ #------------------------------------------------------------
39
+ def value
40
+ if value_set?
41
+ @value
42
+ else
43
+ @default
44
+ end
45
+ end
46
+
47
+ def value=(value)
48
+ @value = value
49
+ @value_set = true
50
+ end
51
+
52
+ def value_printable
53
+ if self.value.instance_of?(Array)
54
+ self.value.join(',')
55
+ elsif [TrueClass, FalseClass].include?(self.value.class)
56
+ self.value.human
57
+ else
58
+ self.value
59
+ end
60
+ end
61
+
62
+ #------------------------------------------------------------
63
+ # Indicates whether or not a value has been set.
64
+ #------------------------------------------------------------
65
+ def value_set?
66
+ @value_set
67
+ end
68
+
69
+ #------------------------------------------------------------
70
+ # Utility: get an argument for output, aligning the short
71
+ # and long forms.
72
+ #------------------------------------------------------------
73
+ def example_arg
74
+ s = self.arg_short ? "#{self.arg_short}," : nil
75
+ l = self.arg_format ? "#{self.arg_long} #{self.arg_format}" : "#{self.arg_long}"
76
+ # " %-3s %s " % [s, l]
77
+ "%-3s %s" % [s, l]
78
+ end
79
+
80
+ end # class
81
+
82
+
83
+ #=============================================================================
84
+ # Handles application user defaults as a singleton. This is specific to
85
+ # MmTool, and is not meant to be a general purpose User Defaults system.
86
+ #=============================================================================
87
+ class MmUserDefaults
88
+
89
+ require 'tty-table'
90
+
91
+ #------------------------------------------------------------
92
+ # Singleton accessor.
93
+ #------------------------------------------------------------
94
+ def self.shared_user_defaults
95
+ unless @self
96
+ @self = self.new
97
+ end
98
+ @self
99
+ end
100
+
101
+ #------------------------------------------------------------
102
+ # Initialize
103
+ #------------------------------------------------------------
104
+ def initialize
105
+ @defaults = {}
106
+ end
107
+
108
+ #------------------------------------------------------------
109
+ # The location on the filesystem where the file exists.
110
+ #------------------------------------------------------------
111
+ def file_path
112
+ PATH_USER_DEFAULTS
113
+ end
114
+
115
+
116
+ #------------------------------------------------------------
117
+ # Creates a new user default object and add it to the
118
+ # collection.
119
+ #------------------------------------------------------------
120
+ def define_default(for_key:, value:)
121
+ @defaults[for_key] = MmUserDefault.new(key:for_key,value:value)
122
+ end
123
+
124
+ #------------------------------------------------------------
125
+ # Sets up initial defaults from a large hash indicating all
126
+ # of the user defaults and attributes for the default.
127
+ # Also sets up the persistence system. If the file doesn't
128
+ # exist, it will be created with the current key-value
129
+ # pairs. If it does exist, existing key-value pairs will
130
+ # be reconciled with current key-value pairs. Finally,
131
+ # values from the file will be applied.
132
+ #------------------------------------------------------------
133
+ def register_defaults(with_hash:)
134
+
135
+ # *Define* each of the possible defaults. These will have
136
+ # default values. We're actually kind of good to go at
137
+ # this point if we don't want to use the rc file.
138
+
139
+ with_hash.each {|k,v| define_default(for_key:k, value:v) }
140
+
141
+ # If the file doesn't exist, create it and add our current
142
+ # key-value pairs to it. Otherwise, read the file, compare
143
+ # it to our current set of key-value pairs, and make
144
+ # adjustments, re-writing the file if necessary.
145
+
146
+ if !File.file?(file_path)
147
+ FileUtils.mkdir_p(File.dirname(file_path))
148
+ File.open(file_path, 'w') { |file| file.write(hash_representation.to_yaml) }
149
+ else
150
+ #noinspection RubyResolve
151
+ #@type [Hash] working
152
+ working = YAML.load(File.read(file_path))
153
+ new = working.select {|k,_| hash_representation.has_key?(k)} # only keeps working items if they are current.
154
+ new = new.merge(hash_representation.select {|k,_| !new.has_key?(k)}) # select the new items.
155
+ File.open(file_path, 'w') { |file| file.write(new.to_yaml) } unless working == new
156
+ new.each {|k,v| self[k] = v }
157
+ end
158
+ end
159
+
160
+ #------------------------------------------------------------
161
+ # Returns all default instances as an array.
162
+ #------------------------------------------------------------
163
+ def all_defaults
164
+ @defaults.values.sort_by(&:name)
165
+ end
166
+
167
+ #------------------------------------------------------------
168
+ # Returns all default instances as a hash, with key and
169
+ # value only. If there's no item label, assume that the
170
+ # default is a command and not an actual setting, and
171
+ # skip it.
172
+ #------------------------------------------------------------
173
+ def hash_representation
174
+ all_defaults.select {|d| d.item_label}
175
+ .collect { |d| [d.name, d.value] }.to_h
176
+ end
177
+
178
+ #------------------------------------------------------------
179
+ # Get a single default instance.
180
+ #------------------------------------------------------------
181
+ def default(key)
182
+ @defaults[key]
183
+ end
184
+
185
+ #------------------------------------------------------------
186
+ # Return the value of a setting by key or nil.
187
+ #------------------------------------------------------------
188
+ def [](key)
189
+ result = default(key)
190
+ result ? result.value : nil
191
+ end
192
+
193
+ #------------------------------------------------------------
194
+ # Set the value of a setting by key.
195
+ #------------------------------------------------------------
196
+ def []=(key, value)
197
+ if default(key)
198
+ default(key).value = value
199
+ else
200
+ define_default(for_key:key, value:value)
201
+ end
202
+ end
203
+
204
+ #------------------------------------------------------------
205
+ # Get and set defaults via methods.
206
+ #------------------------------------------------------------
207
+ def method_missing(method, *args)
208
+ if defines_default?(method) && args.empty?
209
+ self[method]
210
+ elsif method.to_s =~ /^(\w+)=$/ && args.size == 1
211
+ self[Regexp.last_match(1).to_sym] = args[0]
212
+ else
213
+ super
214
+ end
215
+ end
216
+
217
+ #------------------------------------------------------------
218
+ # Ensure that we account for our dynamic methods.
219
+ #------------------------------------------------------------
220
+ def respond_to?(method, include_private = false)
221
+ super || defines_default?(method) || (method =~ /^(\w+)=$/ && defines_default?(Regexp.last_match(1)))
222
+ end
223
+
224
+ #------------------------------------------------------------
225
+ # Does the default exist?
226
+ #------------------------------------------------------------
227
+ def defines_default?(key)
228
+ @defaults.key?(key)
229
+ end
230
+
231
+ #------------------------------------------------------------
232
+ # Utility: get all of the argument help text by group, into
233
+ # a simple hash with group names as keys.
234
+ #------------------------------------------------------------
235
+ def arguments_help_by_group
236
+
237
+ table = {}
238
+
239
+ argument_groups.each do |group|
240
+
241
+ #noinspection RubyResolve
242
+ key = "\n#{C.bold(group)}\n\n"
243
+ table[key] = []
244
+
245
+ @defaults.values.select { |default| default.help_group == group }
246
+ .each do |default|
247
+ width = example_args_by_length[-1].length
248
+ arg = " %-#{width}.#{width}s " % default.example_arg
249
+ des = default.help_desc % default.value
250
+ table[key] << arg + des
251
+ end
252
+ end
253
+ table
254
+ end
255
+
256
+ #------------------------------------------------------------
257
+ # Utility: get an array of argument examples, sorted by
258
+ # length.
259
+ #------------------------------------------------------------
260
+ def example_args_by_length
261
+ @defaults.values
262
+ .sort {|a,b| a.example_arg.length <=> b.example_arg.length}
263
+ .collect {|d| d.example_arg}
264
+ end
265
+
266
+ #------------------------------------------------------------
267
+ # Utility: Return an array of argument groups. They will
268
+ # be in the order that they were added to user defaults.
269
+ #------------------------------------------------------------
270
+ def argument_groups
271
+ @defaults.values
272
+ .collect {|default| default.help_group}
273
+ .uniq
274
+ end
275
+
276
+ #------------------------------------------------------------
277
+ # Returns an array of labels and the current matching value.
278
+ # Accepts a block to add your own pairs.
279
+ #------------------------------------------------------------
280
+ def label_value_pairs
281
+ result = @defaults.values.select {|default| default.item_label}
282
+ .map {|default| ["#{default.item_label}:", default.value_printable]}
283
+ yield result if block_given?
284
+ result
285
+ end
286
+
287
+
288
+ end # class
289
+
290
+ end # module