mm_tool 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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