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,68 @@
1
+ module MmTool
2
+
3
+ #=============================================================================
4
+ # A list of movie files to ignore during :normal and :all scan types. Will
5
+ # keep the on-file list in sync with the in-memory list.
6
+ #=============================================================================
7
+ class MmMovieIgnoreList
8
+
9
+ require 'fileutils'
10
+ require 'yaml'
11
+
12
+ #------------------------------------------------------------
13
+ # Singleton accessor.
14
+ #------------------------------------------------------------
15
+ def self.shared_ignore_list
16
+ unless @self
17
+ @self = self.new
18
+ end
19
+ @self
20
+ end
21
+
22
+ #------------------------------------------------------------
23
+ # Initialize
24
+ #------------------------------------------------------------
25
+ def initialize
26
+ @ignore_list = []
27
+ if !File.file?(file_path)
28
+ FileUtils.mkdir_p(File.dirname(file_path))
29
+ else
30
+ #noinspection RubyResolve
31
+ @ignore_list = YAML.load(File.read(file_path))
32
+ end
33
+ end
34
+
35
+ #------------------------------------------------------------
36
+ # The location on the filesystem where the file exists.
37
+ #------------------------------------------------------------
38
+ def file_path
39
+ PATH_IGNORE_LIST
40
+ end
41
+
42
+ #------------------------------------------------------------
43
+ # Is the given file on the ignore list?
44
+ #------------------------------------------------------------
45
+ def include?(path)
46
+ @ignore_list.include?(path)
47
+ end
48
+
49
+ #------------------------------------------------------------
50
+ # Add a path to the list, and write list to disk.
51
+ #------------------------------------------------------------
52
+ def add(path:)
53
+ new_list = @ignore_list |= [path]
54
+ @ignore_list = new_list.sort
55
+ File.open(file_path, 'w') { |file| file.write(@ignore_list.to_yaml) }
56
+ end
57
+
58
+ #------------------------------------------------------------
59
+ # Remove a path from the list, and update on disk.
60
+ #------------------------------------------------------------
61
+ def remove(path:)
62
+ @ignore_list.delete(path)
63
+ File.open(file_path, 'w') { |file| file.write(@ignore_list.to_yaml) }
64
+ end
65
+
66
+ end # class
67
+
68
+ end # module
@@ -0,0 +1,492 @@
1
+ module MmTool
2
+
3
+ #=============================================================================
4
+ # A stream of an MmMovie. Instances contain simple accessors to the data
5
+ # made available by ffmpeg, and have knowledge on how to generate useful
6
+ # arguments for ffmpeg and mkvpropedit.
7
+ #=============================================================================
8
+ class MmMovieStream
9
+
10
+ require 'streamio-ffmpeg'
11
+ require 'mm_tool/mm_movie'
12
+
13
+ #------------------------------------------------------------
14
+ # Given an array of related files, this class method returns
15
+ # an array of MmMovieStreams reflecting the streams present
16
+ # in each of them.
17
+ #------------------------------------------------------------
18
+ def self.streams(with_files:)
19
+ # Arrays are passed around by reference; when this array is created and
20
+ # used as a reference in each stream, and *also* returned from this class
21
+ # method, everyone will still be using the same reference. It's important
22
+ # below to build up this array without replacing it with another instance.
23
+ streams = []
24
+ with_files.each_with_index do |path, i|
25
+ ff_movie = FFMPEG::Movie.new(path)
26
+ ff_movie.metadata[:streams].each do |stream|
27
+ streams << MmMovieStream.new(stream_data: stream, source_file: path, file_number: i, streams_ref: streams)
28
+ end
29
+ end
30
+ streams
31
+ end
32
+
33
+ #------------------------------------------------------------
34
+ # Initialize
35
+ #------------------------------------------------------------
36
+ def initialize(stream_data:, source_file:, file_number:, streams_ref:)
37
+ @defaults = MmUserDefaults.shared_user_defaults
38
+ @data = stream_data
39
+ @source_file = source_file
40
+ @file_number = file_number
41
+ @streams = streams_ref
42
+ end
43
+
44
+ #------------------------------------------------------------
45
+ # Attribute accessors
46
+ #------------------------------------------------------------
47
+ attr_accessor :file_number
48
+ attr_accessor :source_file
49
+
50
+ #------------------------------------------------------------
51
+ # Property - returns the index of the stream.
52
+ #------------------------------------------------------------
53
+ def index
54
+ @data[:index]
55
+ end
56
+
57
+ #------------------------------------------------------------
58
+ # Property - returns the input specifier of the stream.
59
+ #------------------------------------------------------------
60
+ def input_specifier
61
+ "#{@file_number}:#{index}"
62
+ end
63
+
64
+ #------------------------------------------------------------
65
+ # Property - returns the codec name of the stream.
66
+ #------------------------------------------------------------
67
+ def codec_name
68
+ @data[:codec_name]
69
+ end
70
+
71
+ #------------------------------------------------------------
72
+ # Property - returns the codec type of the stream.
73
+ #------------------------------------------------------------
74
+ def codec_type
75
+ @data[:codec_type]
76
+ end
77
+
78
+ #------------------------------------------------------------
79
+ # Property - returns the coded width of the stream.
80
+ #------------------------------------------------------------
81
+ def coded_width
82
+ @data[:coded_width]
83
+ end
84
+
85
+ #------------------------------------------------------------
86
+ # Property - returns the coded height of the stream.
87
+ #------------------------------------------------------------
88
+ def coded_height
89
+ @data[:coded_height]
90
+ end
91
+
92
+ #------------------------------------------------------------
93
+ # Property - returns the number of channels of the stream.
94
+ #------------------------------------------------------------
95
+ def channels
96
+ @data[:channels]
97
+ end
98
+
99
+ #------------------------------------------------------------
100
+ # Property - returns the channel layout of the stream.
101
+ #------------------------------------------------------------
102
+ def channel_layout
103
+ @data[:channel_layout]
104
+ end
105
+
106
+ #------------------------------------------------------------
107
+ # Property - returns the language of the stream, or 'und'
108
+ # if the language is not defined.
109
+ #------------------------------------------------------------
110
+ def language
111
+ if @data.key?(:tags)
112
+ lang = @data[:tags][:language]
113
+ lang = @data[:tags][:LANGUAGE] unless lang
114
+ lang = 'und' unless lang
115
+ else
116
+ lang = 'und'
117
+ end
118
+ lang
119
+ end
120
+
121
+ #------------------------------------------------------------
122
+ # Property - returns the title of the stream, or nil.
123
+ #------------------------------------------------------------
124
+ def title
125
+ if @data.key?(:tags)
126
+ @data[:tags][:title]
127
+ else
128
+ nil
129
+ end
130
+ end
131
+
132
+ #------------------------------------------------------------
133
+ # Property - returns the disposition flags of the stream as
134
+ # a comma-separated list for compactness.
135
+ #------------------------------------------------------------
136
+ def dispositions
137
+ MmMovie.dispositions
138
+ .collect {|symbol| @data[:disposition][symbol]}
139
+ .join(',')
140
+ end
141
+
142
+ #------------------------------------------------------------
143
+ # Property - returns an appropriate "quality" indicator
144
+ # based on the type of the stream.
145
+ #------------------------------------------------------------
146
+ def quality_01
147
+ if codec_type == 'audio'
148
+ channels
149
+ elsif codec_type == 'video'
150
+ coded_width
151
+ else
152
+ nil
153
+ end
154
+ end
155
+
156
+ #------------------------------------------------------------
157
+ # Property - returns a different appropriate "quality"
158
+ # indicator based on the type of the stream.
159
+ #------------------------------------------------------------
160
+ def quality_02
161
+ if codec_type == 'audio'
162
+ channel_layout
163
+ elsif codec_type == 'video'
164
+ coded_height
165
+ else
166
+ nil
167
+ end
168
+ end
169
+
170
+ #------------------------------------------------------------
171
+ # Property - returns a convenient label indicating the
172
+ # recommended actions for the stream.
173
+ #------------------------------------------------------------
174
+ def action_label
175
+ "#{output_specifier} #{actions.select {|a| a != :interesting}.join(' ')}"
176
+ end
177
+
178
+ #------------------------------------------------------------
179
+ # Property - indicates whether or not the stream is the
180
+ # default stream per its dispositions.
181
+ #------------------------------------------------------------
182
+ def default?
183
+ @data[:disposition][:default] == 1
184
+ end
185
+
186
+ #------------------------------------------------------------
187
+ # Property - indicates whether or not the stream is
188
+ # considered "low quality" based on the application
189
+ # configuration.
190
+ #------------------------------------------------------------
191
+ def low_quality?
192
+ if codec_type == 'audio'
193
+ channels.to_i < @defaults[:min_channels].to_i
194
+ elsif codec_type == 'video'
195
+ coded_width.to_i < @defaults[:min_width].to_i
196
+ else
197
+ false
198
+ end
199
+ end
200
+
201
+ #------------------------------------------------------------
202
+ # Property - stream action includes :drop?
203
+ #------------------------------------------------------------
204
+ def drop?
205
+ actions.include?(:drop)
206
+ end
207
+
208
+ #------------------------------------------------------------
209
+ # Property - stream action includes :copy?
210
+ #------------------------------------------------------------
211
+ def copy?
212
+ actions.include?(:copy)
213
+ end
214
+
215
+ #------------------------------------------------------------
216
+ # Property - stream action includes :transcode?
217
+ #------------------------------------------------------------
218
+ def transcode?
219
+ actions.include?(:transcode)
220
+ end
221
+
222
+ #------------------------------------------------------------
223
+ # Property - stream action includes :set_language?
224
+ #------------------------------------------------------------
225
+ def set_language?
226
+ actions.include?(:set_language)
227
+ end
228
+
229
+ #------------------------------------------------------------
230
+ # Property - stream action includes :interesting?
231
+ #------------------------------------------------------------
232
+ def interesting?
233
+ actions.include?(:interesting)
234
+ end
235
+
236
+ #------------------------------------------------------------
237
+ # Property - indicates whether or not the stream will be
238
+ # unique for its type at output.
239
+ #------------------------------------------------------------
240
+ def output_unique?
241
+ @streams.count {|s| s.codec_type == codec_type && !s.drop? } == 1
242
+ end
243
+
244
+ #------------------------------------------------------------
245
+ # Property - indicates whether or not this stream is the
246
+ # only one of its type.
247
+ #------------------------------------------------------------
248
+ def one_of_a_kind?
249
+ @streams.count {|s| s.codec_type == codec_type && s != self } == 0
250
+ end
251
+
252
+ #------------------------------------------------------------
253
+ # Property - returns the index of the stream in the output
254
+ # file.
255
+ #------------------------------------------------------------
256
+ def output_index
257
+ @streams.select {|s| !s.drop? }.index(self)
258
+ end
259
+
260
+ #------------------------------------------------------------
261
+ # Property - returns a specific output specifier for the
262
+ # stream, such as v:0 or a:2.
263
+ #------------------------------------------------------------
264
+ def output_specifier
265
+ idx = @streams.select {|s| s.codec_type == codec_type && !s.drop?}.index(self)
266
+ idx ? "#{codec_type[0]}:#{idx}" : ' ⬇ '
267
+ end
268
+
269
+ #------------------------------------------------------------
270
+ # Property - returns the -i input instruction for this
271
+ # stream.
272
+ #------------------------------------------------------------
273
+ def instruction_input
274
+ src = if @file_number == 0
275
+ File.join(File.dirname(@source_file), File.basename(@source_file, '.*') + @defaults[:suffix] + File.extname(@source_file))
276
+ else
277
+ @source_file
278
+ end
279
+ "-i \"#{src}\" \\"
280
+ end
281
+
282
+ #------------------------------------------------------------
283
+ # Property - returns the -map instruction for this stream,
284
+ # according to the action(s) determined.
285
+ #------------------------------------------------------------
286
+ def instruction_map
287
+ drop? ? nil : "-map #{input_specifier} \\"
288
+ end
289
+
290
+ #------------------------------------------------------------
291
+ # Property - returns an instruction for handling the stream,
292
+ # according to the action(s) determined.
293
+ #------------------------------------------------------------
294
+ def instruction_action
295
+ if copy?
296
+ "-codec:#{output_specifier} copy \\"
297
+ elsif transcode?
298
+ if codec_type == 'audio'
299
+ encode_to = @defaults[:codecs_audio_preferred][0]
300
+ elsif codec_type == 'video'
301
+ encode_to = @defaults[:codecs_video_preferred][0]
302
+ else
303
+ raise Exception.new "Error: somehow the program branched where it shouldn't have."
304
+ end
305
+ "-codec:#{output_specifier} #{encoder_string(for_codec: encode_to)} \\"
306
+ else
307
+ nil
308
+ end
309
+ end
310
+
311
+ #------------------------------------------------------------
312
+ # Property - returns an instruction for setting the metadata
313
+ # of the stream, if necessary.
314
+ #------------------------------------------------------------
315
+ def instruction_metadata
316
+ # We only want to set fixed_lang if options allow us to fix the language,
317
+ # and we want to set subtitle language from the filename, if applicable.
318
+ fixed_lang = @defaults[:fix_undefined_language] ? @defaults[:undefined_language] : nil
319
+ lang = subtitle_file_language ? subtitle_file_language : fixed_lang
320
+ set_language = set_language? ? "language=#{lang} " : nil
321
+ set_title = title && ! @defaults[:ignore_titles] ? "title=\"#{title}\" " : nil
322
+
323
+ if set_language || set_title
324
+ "-metadata:s:#{output_specifier} #{set_language}#{set_title}\\"
325
+ else
326
+ nil
327
+ end
328
+ end
329
+
330
+ #------------------------------------------------------------
331
+ # Property - returns an instruction for setting the stream's
332
+ # default disposition, if necessary.
333
+ #------------------------------------------------------------
334
+ def instruction_disposition
335
+ set_disposition = output_unique? && !default? && !drop? ? "default " : nil
336
+
337
+ if set_disposition
338
+ "-disposition:#{output_specifier} #{set_disposition}\\"
339
+ else
340
+ nil
341
+ end
342
+ end
343
+
344
+
345
+ #============================================================
346
+ private
347
+ #============================================================
348
+
349
+
350
+ #------------------------------------------------------------
351
+ # Property - returns an array of actions that are suggested
352
+ # for the stream based on quality, language, codec, etc.
353
+ #------------------------------------------------------------
354
+ def actions
355
+
356
+ #------------------------------------------------------------
357
+ # Note: logic below a result of Karnaugh mapping of the
358
+ # selection truth table for each desired action. There's
359
+ # probably an excel file somewhere in the repository.
360
+ #------------------------------------------------------------
361
+
362
+ if @actions.nil?
363
+ @actions = []
364
+
365
+ #––––––––––––––––––––––––––––––––––––––––––––––––––
366
+ # subtitle stream handler
367
+ #––––––––––––––––––––––––––––––––––––––––––––––––––
368
+ if codec_type == 'subtitle'
369
+
370
+ a = @defaults[:keep_langs_subs]&.include?(language)
371
+ b = @defaults[:codecs_subs_preferred]&.include?(codec_name)
372
+ c = language.downcase == 'und'
373
+ d = title != nil && ! @defaults[:ignore_titles]
374
+
375
+ if (!a && !c) || (!b)
376
+ @actions |= [:drop]
377
+ else
378
+ @actions |= [:copy]
379
+ end
380
+
381
+ if (b && c) && (@defaults[:fix_undefined_language])
382
+ @actions |= [:set_language]
383
+ end
384
+
385
+ if (!a || !b || c || (d))
386
+ @actions |= [:interesting]
387
+ end
388
+
389
+ #––––––––––––––––––––––––––––––––––––––––––––––––––
390
+ # video stream handler
391
+ #––––––––––––––––––––––––––––––––––––––––––––––––––
392
+ elsif codec_type == 'video'
393
+
394
+ a = codec_name.downcase == 'mjpeg'
395
+ b = @defaults[:codecs_video_preferred]&.include?(codec_name)
396
+ c = @defaults[:keep_langs_video]&.include?(language)
397
+ d = language.downcase == 'und'
398
+ e = title != nil && ! @defaults[:ignore_titles]
399
+ f = @defaults[:scan_type] == 'quality' && low_quality?
400
+
401
+ if (a)
402
+ @actions |= [:drop]
403
+ end
404
+
405
+ if (!a && b)
406
+ @actions |= [:copy]
407
+ end
408
+
409
+ if (!a && !b)
410
+ @actions |= [:transcode]
411
+ end
412
+
413
+ if (!a && d) && (@defaults[:fix_undefined_language])
414
+ @actions |= [:set_language]
415
+ end
416
+
417
+ if (a || !b || !c || d || e || f)
418
+ @actions |= [:interesting]
419
+ end
420
+
421
+ #––––––––––––––––––––––––––––––––––––––––––––––––––
422
+ # audio stream handler
423
+ #––––––––––––––––––––––––––––––––––––––––––––––––––
424
+ elsif codec_type == 'audio'
425
+
426
+ a = @defaults[:codecs_audio_preferred]&.include?(codec_name)
427
+ b = @defaults[:keep_langs_audio]&.include?(language)
428
+ c = language.downcase == 'und'
429
+ d = title != nil && ! @defaults[:ignore_titles]
430
+ e = @defaults[:scan_type] == 'quality' && low_quality?
431
+
432
+ if (!b && !c)
433
+ @actions |= one_of_a_kind? ? [:set_language] : [:drop]
434
+ end
435
+
436
+ if (a && b) || (a && !b && c)
437
+ @actions |= [:copy]
438
+ end
439
+
440
+ if (!a && !b && c) || (!a && b)
441
+ @actions |= [:transcode]
442
+ end
443
+
444
+ if (c) && (@defaults[:fix_undefined_language])
445
+ @actions |= [:set_language]
446
+ end
447
+
448
+ if (!a || !b || c || d || e)
449
+ @actions |= [:interesting]
450
+ end
451
+
452
+ #––––––––––––––––––––––––––––––––––––––––––––––––––
453
+ # other stream handler
454
+ #––––––––––––––––––––––––––––––––––––––––––––––––––
455
+ else
456
+ @actions |= [:drop]
457
+ end
458
+ end # if @actions.nil?
459
+
460
+ @actions
461
+ end # actions
462
+
463
+ #------------------------------------------------------------
464
+ # Given a codec, return the ffmpeg encoder string.
465
+ #------------------------------------------------------------
466
+ def encoder_string(for_codec:)
467
+ case for_codec.downcase
468
+ when 'hevc'
469
+ "libx265 -crf 28 -preset slow"
470
+ when 'h264'
471
+ "libx264 -crf 23 -preset slow"
472
+ when 'aac'
473
+ "libfdk_aac"
474
+ else
475
+ raise Exception.new "Error: somehow an unsupported codec '#{for_codec}' was specified."
476
+ end
477
+ end
478
+
479
+ #------------------------------------------------------------
480
+ # If the source file is an srt, and there's a language, and
481
+ # it's in the approved language list, then return it;
482
+ # otherwise return nil.
483
+ #------------------------------------------------------------
484
+ def subtitle_file_language
485
+ langs = @defaults[:keep_langs_subs]&.join('|')
486
+ lang = @source_file.match(/^.*\.(#{langs})\.srt$/)
487
+ lang ? lang[1] : nil
488
+ end
489
+
490
+ end # class
491
+
492
+ end # module