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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: eb195a344b506ce9706580970d5e8db8843bf53b537161d63577dc84577089dc
4
+ data.tar.gz: b06f0eff5f6b0aeddd08b5ac1792d9cdf0869df5babab29fb771f9d3fac28e9d
5
+ SHA512:
6
+ metadata.gz: a68290286f7db0bbcf5a0440c22ad3a213224439530c73280e70426fc52601a2d52d9c9dac278c7558de9b06317b8e284aae78ca6031b7b74f56f3b699c5823c
7
+ data.tar.gz: 769b79d23c56d70d9bbf4fa857629bad1376e332e7b6c87280a86d7d21dd0c54fe029c222a000dd7861c7aa764988372f81fc4e2dc754de3110bd79dd8a0bcb6
data/.gitattributes ADDED
@@ -0,0 +1,4 @@
1
+ *.html linguist-documentation
2
+ *.css linguist-documentation
3
+ *.erb linguist-documentation
4
+ *.js linguist-documentation
data/.gitignore ADDED
@@ -0,0 +1,24 @@
1
+ # Rubymine noise
2
+ .idea/
3
+
4
+ # BBedit noise
5
+ *.bbprojectd
6
+
7
+ # Mac OS X noise
8
+ .DS_Store
9
+
10
+ # Ignore bundler lock files
11
+ Gemfile.lock
12
+
13
+ # Ignore pkg folder
14
+ /pkg
15
+
16
+ # Ignore build folders
17
+ build/*
18
+
19
+ # Yard cruft
20
+ /doc/
21
+ /.yardoc
22
+
23
+ # Aruba
24
+ /tmp/
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License
2
+ ===============
3
+
4
+ Copyright (c) 2020 Jim Derry
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,15 @@
1
+ mm_tool, a multimedia tool
2
+ ==========================
3
+ [![Gem Version](https://badge.fury.io/rb/mm_tool_.svg)](https://badge.fury.io/rb/mm_tool_)
4
+
5
+
6
+ # About
7
+
8
+ tbd
9
+
10
+
11
+ # Change log
12
+
13
+ - 0.1.0
14
+
15
+ - Initial release.
data/Rakefile ADDED
@@ -0,0 +1,2 @@
1
+ require "bundler/gem_tasks"
2
+ task :default => :spec
data/Truth Tables.xlsx ADDED
Binary file
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "mm_tool"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/mm_tool ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ #=============================================================================
4
+ # This module consolidates all of the classes the make up a complete MmTool
5
+ # application, and is spread throughout several files based mostly on the
6
+ # classes they contain.
7
+ #=============================================================================
8
+ module MmTool
9
+ require_relative '../lib/mm_tool'
10
+ end
11
+
12
+
13
+ #=============================================================================
14
+ # Main
15
+ #=============================================================================
16
+
17
+ cli = MmTool::MmToolCli.new(MmTool::ApplicationMain.shared_application)
18
+ cli.validate_prerequisites
19
+ cli.run(ARGV)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/mm_tool.rb ADDED
@@ -0,0 +1,21 @@
1
+ # Setup our load paths
2
+ libdir = File.expand_path(File.dirname(__FILE__))
3
+ $LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir)
4
+
5
+ require "mm_tool/output_helper"
6
+ require "mm_tool/user_defaults"
7
+ require "mm_tool/version"
8
+
9
+ require "mm_tool/application_main"
10
+ require "mm_tool/mm_tool_cli"
11
+
12
+ require 'mm_tool/mm_movie'
13
+ require 'mm_tool/mm_movie_stream'
14
+
15
+ require 'mm_tool/mm_movie_ignore_list'
16
+ require "mm_tool/mm_user_defaults"
17
+
18
+ module MmTool
19
+ class Error < StandardError
20
+ end
21
+ end
@@ -0,0 +1,172 @@
1
+ module MmTool
2
+
3
+ #=============================================================================
4
+ # The main application.
5
+ #=============================================================================
6
+ class ApplicationMain
7
+
8
+ require 'mm_tool.rb'
9
+
10
+ #------------------------------------------------------------
11
+ # Attributes
12
+ #------------------------------------------------------------
13
+ attr_accessor :tempfile
14
+
15
+ #------------------------------------------------------------
16
+ # Initialize
17
+ #------------------------------------------------------------
18
+ def initialize
19
+ @tempfile = nil
20
+ @defaults = MmUserDefaults.shared_user_defaults
21
+ end
22
+
23
+ #------------------------------------------------------------
24
+ # Singleton accessor
25
+ #------------------------------------------------------------
26
+ def self.shared_application
27
+ unless @self
28
+ @self = self.new
29
+ end
30
+ @self
31
+ end
32
+
33
+ #------------------------------------------------------------
34
+ # Output a message to the screen, and if applicable, to
35
+ # the temporary file for later opening.
36
+ #------------------------------------------------------------
37
+ def output(message, command = false)
38
+ puts message
39
+ if @tempfile
40
+ if command
41
+ message = message + "\n"
42
+ else
43
+ message = message.lines.collect {|line| "# #{line}"}.join + "\n"
44
+ end
45
+ @tempfile&.write(message)
46
+ end
47
+ end
48
+
49
+ #------------------------------------------------------------
50
+ # Return the transcode file header.
51
+ #------------------------------------------------------------
52
+ def transcode_script_header
53
+ <<~HEREDOC
54
+ #!/bin/sh
55
+
56
+ # Check this file, make any changes, and save it. It will be executed as soon
57
+ # as you close it. By default, this script will exit per the exit command
58
+ # below. Please further confirm that you wish to proceed with the proposed
59
+ # actions by commenting or removing the line below.
60
+
61
+ exit 1
62
+
63
+ HEREDOC
64
+ end
65
+
66
+ #------------------------------------------------------------
67
+ # Return the report header.
68
+ #------------------------------------------------------------
69
+ #noinspection RubyResolve
70
+ def information_header
71
+ info_table_src = TTY::Table.new
72
+ @defaults.label_value_pairs.each {|pair| info_table_src << pair}
73
+ info_table_src << ["Disposition Columns:", MmMovie.dispositions.join(', ')]
74
+ info_table_src << ["Transcode File Location:", self.tempfile ? tempfile&.path : 'n/a']
75
+
76
+ info_table = C.bold("Looking for file(s) and processing them with the following options:\n")
77
+ info_table << info_table_src.render(:basic) do |renderer|
78
+ a = @defaults.label_value_pairs.map {|p| p[0].length }.max + 1
79
+ b = OutputHelper.console_width - a - 2
80
+ renderer.alignments = [:right, :left]
81
+ renderer.multiline = true
82
+ renderer.column_widths = [a,b]
83
+ end << "\n\n"
84
+ end
85
+
86
+ #------------------------------------------------------------
87
+ # The main run loop, to be run for each file.
88
+ #------------------------------------------------------------
89
+ def run_loop(file_name)
90
+
91
+ if @defaults[:ignore_files]
92
+ MmMovieIgnoreList.shared_ignore_list.add(path: file_name)
93
+ output("Note: added #{file_name} to the list of files to be ignored.")
94
+ elsif @defaults[:unignore_files]
95
+ MmMovieIgnoreList.shared_ignore_list.remove(path: file_name)
96
+ output("Note: removed #{file_name} to the list of files to be ignored.")
97
+ else
98
+ @file_count[:processed] = @file_count[:processed] + 1
99
+ movie = MmMovie.new(with_file: file_name)
100
+ a = movie.interesting?
101
+ b = MmMovieIgnoreList.shared_ignore_list.include?(file_name)
102
+ c = movie.low_quality?
103
+ s = @defaults[:scan_type]&.to_sym
104
+
105
+ if (s == :normal && a && !b && !c) ||
106
+ (s == :all && !b) ||
107
+ (s == :flagged && b) ||
108
+ (s == :quality && c ) ||
109
+ (s == :force)
110
+
111
+ @file_count[:displayed] = @file_count[:displayed] + 1
112
+ output(file_name)
113
+ output(movie.format_table)
114
+ output(movie.stream_table)
115
+ output("#{movie.command_rename} ; \\", true)
116
+ output("#{movie.command_transcode} ; \\", true)
117
+ output(movie.command_review_post, true)
118
+ output("\n\n", true)
119
+ end
120
+ end # if
121
+ end
122
+
123
+ #------------------------------------------------------------
124
+ # Run the application with the given file/directory.
125
+ #------------------------------------------------------------
126
+ def run(file_or_dir)
127
+
128
+ @file_count = { :processed => 0, :displayed => 0 }
129
+
130
+ if @defaults[:transcode]
131
+ @tempfile = Tempfile.create(['mm_tool-', '.sh'])
132
+ @tempfile&.write(transcode_script_header)
133
+ # @tempfile.flush
134
+ end
135
+
136
+ if @defaults[:info_header]
137
+ output information_header
138
+ end
139
+
140
+ if File.file?(file_or_dir)
141
+
142
+ original_scan_type = @defaults[:scan_type]
143
+ @defaults[:scan_type] = :force
144
+ run_loop(file_or_dir)
145
+ @defaults[:scan_type] = original_scan_type
146
+
147
+ elsif File.directory?(file_or_dir)
148
+
149
+ extensions = @defaults[:container_files]&.join(',')
150
+ Dir.chdir(file_or_dir) do
151
+ Dir.glob("**/*.{#{extensions}}").map {|path| File.expand_path(path) }.sort.each do |file|
152
+ run_loop(file)
153
+ end
154
+ end
155
+
156
+ else
157
+ output "Error: Execution should never have reached this point."
158
+ exit 1
159
+ end
160
+
161
+ output("#{File.basename($0)} processed #{@file_count[:processed]} files and displayed data for #{@file_count[:displayed]} of them.")
162
+
163
+ ensure
164
+ if @tempfile
165
+ @tempfile&.close
166
+ # @tempfile.unlink
167
+ end
168
+ end # run
169
+
170
+ end # class
171
+
172
+ end # module
@@ -0,0 +1,217 @@
1
+ module MmTool
2
+
3
+ #=============================================================================
4
+ # A movie as a self contained class. Instances of this class consist of
5
+ # one or more MmMovieStream instances, and contains intelligence about itself
6
+ # so that it can provide commands to ffmpeg and/or mkvpropedit as required.
7
+ # Upon creation it must be provided with a filename.
8
+ #=============================================================================
9
+ class MmMovie
10
+
11
+ require 'streamio-ffmpeg'
12
+ require 'tty-table'
13
+ require 'bytesize'
14
+
15
+ #------------------------------------------------------------
16
+ # This class method returns the known dispositions supported
17
+ # by ffmpeg. This array also reflects the output orders in
18
+ # the dispositions table field. Not combining them would
19
+ # result in a too-long table row.
20
+ #------------------------------------------------------------
21
+ def self.dispositions
22
+ %i(default dub original comment lyrics karaoke forced hearing_impaired visual_impaired clean_effects attached_pic timed_thumbnails)
23
+ end
24
+
25
+ #------------------------------------------------------------
26
+ # Initialize
27
+ #------------------------------------------------------------
28
+ def initialize(with_file:)
29
+ @defaults = MmUserDefaults.shared_user_defaults
30
+ @streams = MmMovieStream::streams(with_files: all_paths(with_file: with_file))
31
+ @format_metadata = FFMPEG::Movie.new(with_file).metadata[:format]
32
+ end
33
+
34
+ #------------------------------------------------------------
35
+ # Get the file-level 'duration' metadata.
36
+ #------------------------------------------------------------
37
+ def format_duration
38
+ seconds = @format_metadata[:duration]
39
+ seconds ? Time.at(seconds.to_i).utc.strftime("%H:%M:%S") : nil
40
+ end
41
+
42
+ #------------------------------------------------------------
43
+ # Get the file-level 'size' metadata.
44
+ #------------------------------------------------------------
45
+ def format_size
46
+ size = @format_metadata[:size]
47
+ size ? ByteSize.new(size) : 'unknown'
48
+ end
49
+
50
+ #------------------------------------------------------------
51
+ # Get the file-level 'title' metadata.
52
+ #------------------------------------------------------------
53
+ def format_title
54
+ @format_metadata&.dig(:tags, :title)
55
+ end
56
+
57
+ #------------------------------------------------------------
58
+ # Indicates whether any of the streams are of a lower
59
+ # quality than desired by the user.
60
+ #------------------------------------------------------------
61
+ def low_quality?
62
+ @streams.count {|stream| stream.low_quality?} > 0
63
+ end
64
+
65
+ #------------------------------------------------------------
66
+ # Indicates whether any of the streams are interesting.
67
+ #------------------------------------------------------------
68
+ def interesting?
69
+ @streams.count {|stream| stream.interesting?} > 0 || format_title
70
+ end
71
+
72
+ #------------------------------------------------------------
73
+ # Get the rendered text of the format_table.
74
+ #------------------------------------------------------------
75
+ def format_table
76
+ unless @format_table
77
+ @format_table = format_table_datasource.render(:basic) do |renderer|
78
+ renderer.column_widths = [10,10, 160]
79
+ renderer.multiline = true
80
+ renderer.padding = [0,1]
81
+ renderer.width = 1000
82
+ end
83
+ end
84
+ @format_table
85
+ end
86
+
87
+ #------------------------------------------------------------
88
+ # For the given table, get the rendered text of the table
89
+ # for output.
90
+ #------------------------------------------------------------
91
+ def stream_table
92
+ unless @stream_table
93
+ @stream_table = stream_table_datasource.render(:unicode) do |renderer|
94
+ renderer.alignments = [:center, :left, :left, :right, :right, :left, :left, :left, :left]
95
+ renderer.column_widths = [5,10,10,5,10,5,23,50,35]
96
+ renderer.multiline = true
97
+ renderer.padding = [0,1]
98
+ renderer.width = 1000
99
+ end # do
100
+ end
101
+ @stream_table
102
+ end
103
+
104
+ #------------------------------------------------------------
105
+ # The complete command to rename the main input file to
106
+ # include a tag indicating that it's the original.
107
+ #------------------------------------------------------------
108
+ def command_rename
109
+ src = @streams[0].source_file
110
+ dst = File.join(File.dirname(src), File.basename(src, '.*') + @defaults[:suffix] + File.extname(src))
111
+ "mv \"#{src}\" \"#{dst}\""
112
+ end
113
+
114
+ #------------------------------------------------------------
115
+ # The complete, proposed ffmpeg command to transcode the
116
+ # input file to an output file. It uses the 'new_input_path'
117
+ # as the input file.
118
+ #------------------------------------------------------------
119
+ def command_transcode
120
+ command = ["ffmpeg \\"]
121
+ @streams.each {|stream| command |= [" #{stream.instruction_input}"] if stream.instruction_input }
122
+ @streams.each {|stream| command << " #{stream.instruction_map}" if stream.instruction_map }
123
+ @streams.each {|stream| command << " #{stream.instruction_action}" if stream.instruction_action }
124
+ @streams.each {|stream| command << " #{stream.instruction_disposition}" if stream.instruction_disposition }
125
+ @streams.each {|stream| command << " #{stream.instruction_metadata}" if stream.instruction_metadata }
126
+ command << " -metadata title=\"#{format_title}\" \\" if format_title
127
+ command << " \"#{output_path}\""
128
+ command.join("\n")
129
+ end
130
+
131
+ #------------------------------------------------------------
132
+ # The complete command to view the output file after
133
+ # running the transcode command
134
+ #------------------------------------------------------------
135
+ def command_review_post
136
+ "\"#{$0}\" --no-use-external-subs \"#{output_path}\""
137
+ end
138
+
139
+
140
+ #============================================================
141
+ private
142
+ #============================================================
143
+
144
+ #------------------------------------------------------------
145
+ # Given the initial file, return an array of the initial
146
+ # file and associated SRTs, which are valid if they have
147
+ # no language, or a language specified in options.
148
+ #------------------------------------------------------------
149
+ def all_paths(with_file:)
150
+ all_paths = [with_file]
151
+ if @defaults[:use_external_subs]
152
+ base_path = File.join(File.dirname(with_file), File.basename(with_file, '.*'))
153
+ all_paths |= ([""] | @defaults[:keep_langs_subs]&.map {|lang| ".#{lang}" })
154
+ .select {|lang| File.file?("#{base_path}#{lang}.srt")}
155
+ .map {|lang| "#{base_path}#{lang}.srt"}
156
+ end
157
+ all_paths
158
+ end
159
+
160
+ #------------------------------------------------------------
161
+ # The name of the proposed output file, if different from
162
+ # the input file. This would be set in the event that the
163
+ # container of the input file is not one of the approved
164
+ # containers.
165
+ #------------------------------------------------------------
166
+ def output_path
167
+ path = @streams[0].source_file
168
+ if @defaults[:containers_preferred]&.include?(File.extname(path))
169
+ path
170
+ else
171
+ File.join(File.dirname(path), File.basename(path, '.*') + '.' + @defaults[:containers_preferred][0])
172
+ end
173
+ end
174
+
175
+ #------------------------------------------------------------
176
+ # Return a TTY::Table of the relevant format data.
177
+ #------------------------------------------------------------
178
+ def format_table_datasource
179
+ unless @format_table
180
+ @format_table = TTY::Table.new(header: %w(Duration: Size: Title:))
181
+ @format_table << [format_duration, format_size, format_title]
182
+ end
183
+ @format_table
184
+ end
185
+
186
+ #------------------------------------------------------------
187
+ # Return a TTY::Table of the movie, populated with the
188
+ # pertinent data of each stream.
189
+ #------------------------------------------------------------
190
+ def stream_table_datasource
191
+ unless @table
192
+ # Ensure that when we add the headers, they specifically are left-aligned.
193
+ headers = %w(index codec type w/# h/layout lang disposition title action(s))
194
+ .map { |header| {:value => header, :alignment => :left} }
195
+
196
+ @table = TTY::Table.new(header: headers)
197
+
198
+ @streams.each do |stream|
199
+ row = []
200
+ row << stream.input_specifier
201
+ row << stream.codec_name
202
+ row << stream.codec_type
203
+ row << stream.quality_01
204
+ row << stream.quality_02
205
+ row << stream.language
206
+ row << stream.dispositions
207
+ row << stream.title
208
+ row << stream.action_label
209
+ @table << row
210
+ end
211
+ end
212
+ @table
213
+ end # table
214
+
215
+ end # class
216
+
217
+ end # module