module MmTool #============================================================================= # A movie as a self contained class. Instances of this class consist of # one or more MmMovieStream instances, and contains intelligence about itself # so that it can provide commands to ffmpeg and/or mkvpropedit as required. # Upon creation it must be provided with a filename. #============================================================================= class MmMovie require 'streamio-ffmpeg' require 'tty-table' require 'bytesize' #------------------------------------------------------------ # This class method returns the known dispositions supported # by ffmpeg. This array also reflects the output orders in # the dispositions table field. Not combining them would # result in a too-long table row. #------------------------------------------------------------ def self.dispositions %i(default dub original comment lyrics karaoke forced hearing_impaired visual_impaired clean_effects attached_pic timed_thumbnails) end #------------------------------------------------------------ # Initialize #------------------------------------------------------------ def initialize(with_file:) @defaults = MmUserDefaults.shared_user_defaults @streams = MmMovieStream::streams(with_files: all_paths(with_file: with_file), owner_ref: self) @format_metadata = FFMPEG::Movie.new(with_file).metadata[:format] end #------------------------------------------------------------ # Get the file-level 'duration' metadata. #------------------------------------------------------------ def format_duration seconds = @format_metadata[:duration] seconds ? Time.at(seconds.to_i).utc.strftime("%H:%M:%S") : nil end #------------------------------------------------------------ # Get the file-level 'size' metadata. #------------------------------------------------------------ def format_size size = @format_metadata[:size] size ? ByteSize.new(size) : 'unknown' end #------------------------------------------------------------ # Get the file-level 'bitrate' metadata. #------------------------------------------------------------ def format_bitrate size = @format_metadata[:bit_rate] ByteSize.new(size).to_kb.to_i.to_s + "K" end #------------------------------------------------------------ # Get the file-level 'bitrate' metadata. #------------------------------------------------------------ def raw_bitrate @format_metadata[:bit_rate].to_i end #------------------------------------------------------------ # Get the file-level 'title' metadata. #------------------------------------------------------------ def format_title @format_metadata&.dig(:tags, :title) end #------------------------------------------------------------ # Get the file-level 'MM_TOOL_ENCODED' metadata. #------------------------------------------------------------ def format_mm_tool_encoded @format_metadata&.dig(:tags, :MM_TOOL_ENCODED)&.downcase == 'true' end #------------------------------------------------------------ # Get the file-level 'MM_TOOL_WRITTEN' metadata. #------------------------------------------------------------ def format_mm_tool_written @format_metadata&.dig(:tags, :MM_TOOL_WRITTEN)&.downcase == 'true' end #------------------------------------------------------------ # Indicates whether any of the streams are of a lower # quality than desired by the user. #------------------------------------------------------------ def has_low_quality_streams? @streams.count {|stream| stream.low_quality?} > 0 end #------------------------------------------------------------ # Indicates whether or not a file has at least one high- # quality video stream and high-quality audio stream. #------------------------------------------------------------ def meets_minimum_quality? @streams.count {|stream| stream.codec_type == 'audio' && !stream.low_quality? } > 0 && @streams.count {|stream| stream.codec_type == 'video' && !stream.low_quality? } > 0 end #------------------------------------------------------------ # Indicates whether any of the streams are interesting. #------------------------------------------------------------ def interesting? @streams.count {|stream| stream.interesting?} > 0 || format_title end #------------------------------------------------------------ # Indicates whether any of the streams are to be modified # in any form. #------------------------------------------------------------ def modify_streams? number_of_copy_only = @streams.count {|stream| stream.copy_only?} number_of_streams = @streams.count number_of_streams > number_of_copy_only # result = @streams.count {|stream| stream.copy_only?} < @streams.count # result end #------------------------------------------------------------ # Get the rendered text of the format_table. #------------------------------------------------------------ def format_table unless @format_table @format_table = format_table_datasource.render(:basic) do |renderer| renderer.column_widths = [10,10,10,12,10,123] renderer.multiline = true renderer.padding = [0,1] renderer.width = 192 end end @format_table end #------------------------------------------------------------ # For the given table, get the rendered text of the table # for output. #------------------------------------------------------------ def stream_table unless @stream_table @stream_table = stream_table_datasource.render(:unicode) do |renderer| renderer.alignments = [:center, :left, :left, :right, :right, :left, :left, :left, :left, :left] renderer.column_widths = [5,10,10,5,10,5,23,50,8,35] renderer.multiline = true renderer.padding = [0,1] renderer.width = 192 end # do end @stream_table end #------------------------------------------------------------ # The complete command to rename the main input file to # include a tag indicating that it's the original. #------------------------------------------------------------ def command_rename src = @streams[0].source_file dst = File.join(File.dirname(src), File.basename(src, '.*') + @defaults[:suffix] + File.extname(src)) "mv \"#{src}\" \"#{dst}\"" end #------------------------------------------------------------ # The complete, proposed ffmpeg command to transcode the # input file to an output file. It uses the 'new_input_path' # as the input file. #------------------------------------------------------------ def command_transcode command = ["ffmpeg \\"] command << " -hide_banner \\" command << " -loglevel error \\" command << " -stats \\" @streams.each {|stream| command |= [" #{stream.instruction_input}"] if stream.instruction_input } @streams.each {|stream| command << " #{stream.instruction_map}" if stream.instruction_map } @streams.each {|stream| command << " #{stream.instruction_action}" if stream.instruction_action } @streams.each {|stream| command << " #{stream.instruction_disposition}" if stream.instruction_disposition } @streams.each do |stream| stream.instruction_metadata.each { |instruction| command << " #{instruction}" } end if @defaults[:containers_preferred][0].downcase == 'mkv' command << " -metadata MM_TOOL_ENCODED=\"true\" \\" if @streams.count {|stream| stream.transcode?} > 0 command << " -metadata MM_TOOL_WRITTEN=\"true\" \\" if modify_streams? end command << " -metadata title=\"#{format_title}\" \\" if format_title command << " \"#{output_path}\"" command.join("\n") end #------------------------------------------------------------ # The complete command to view the output file after # running the transcode command #------------------------------------------------------------ def command_review_post "\"#{$0}\" -is --no-use-external-subs \"#{output_path}\"" end #============================================================ private #============================================================ #------------------------------------------------------------ # Given the initial file, return an array of the initial # file and associated SRTs, which are valid if they have # no language, or a language specified in options. #------------------------------------------------------------ def all_paths(with_file:) all_paths = [with_file] if @defaults[:use_external_subs] base_path = File.join(File.dirname(with_file), File.basename(with_file, '.*')) all_paths |= ([""] | @defaults[:keep_langs_subs]&.map {|lang| ".#{lang}" }) .select {|lang| File.file?("#{base_path}#{lang}.srt")} .map {|lang| "#{base_path}#{lang}.srt"} end all_paths end #------------------------------------------------------------ # The name of the proposed output file, if different from # the input file. This would be set in the event that the # container of the input file is not one of the approved # containers. #------------------------------------------------------------ def output_path path = @streams[0].source_file if @defaults[:containers_preferred]&.include?(File.extname(path)) path else File.join(File.dirname(path), File.basename(path, '.*') + '.' + @defaults[:containers_preferred][0]) end end #------------------------------------------------------------ # Return a TTY::Table of the relevant format data. #------------------------------------------------------------ def format_table_datasource unless @format_table @format_table = TTY::Table.new(header: ['Duration:', 'Size:', 'Bitrate:', 'mm_encoded:', 'mm_wrote:', 'Title:']) @format_table << [format_duration, format_size, format_bitrate, format_mm_tool_encoded, format_mm_tool_written, format_title] end @format_table end #------------------------------------------------------------ # Return a TTY::Table of the movie, populated with the # pertinent data of each stream. #------------------------------------------------------------ def stream_table_datasource unless @table # Ensure that when we add the headers, they specifically are left-aligned. headers = ['index', 'codec', 'type', 'w/#', 'h/layout', 'lang', 'disposition', 'title', 'touched?', 'action(s)'] .map { |header| {:value => header, :alignment => :left} } @table = TTY::Table.new(header: headers) @streams.each do |stream| row = [] row << stream.input_specifier row << stream.codec_name row << stream.codec_type row << stream.quality_01 row << stream.quality_02 row << stream.language row << stream.dispositions row << stream.title row << stream.mm_tool_encoded_stream row << stream.action_label @table << row end end @table end # table end # class end # module