diff --git a/lib/mediainfo.rb b/lib/mediainfo.rb index 9d9651d..51f6d7a 100644 --- a/lib/mediainfo.rb +++ b/lib/mediainfo.rb @@ -1,4 +1,6 @@ require "forwardable" +require "uri" +require "open-uri" require "mediainfo/string" require "mediainfo/attr_readers" @@ -8,91 +10,91 @@ Mediainfo is a class wrapping [the mediainfo CLI](http://mediainfo.sourceforge.net). ## Installation - + $ gem install mediainfo - + ## Usage - + info = Mediainfo.new "/path/to/file" - -That will issue the system call to `mediainfo` and parse the output. + +That will issue the system call to `mediainfo` and parse the output. You can specify an alternate path if necessary: - + Mediainfo.path = "/opt/local/bin/mediainfo" - + Once you have an info object, you can start inspecting streams and general metadata. - + info.streams.count # 2 info.audio? # true info.video? # true info.image? # false - -When inspecting specific types of streams, you have a couple general API options. The -first approach assumes one stream of a given type, a common scenario in many video files, + +When inspecting specific types of streams, you have a couple general API options. The +first approach assumes one stream of a given type, a common scenario in many video files, for example. - + info.video.count # 1 info.audio.count # 1 info.video.duration # 120 (seconds) - -Sometimes you'll have more than one stream of a given type. Quicktime files can often + +Sometimes you'll have more than one stream of a given type. Quicktime files can often contain artifacts like this from somebody editing a more 'normal' file. - + info = Mediainfo.new "funky.mov" - + info.video? # true info.video.count # 2 info.video.duration # raises SingleStreamAPIError ! info.video[0].duration # 120 info.video[1].duration # 10 - -For some more usage examples, please see the very reasonable test suite accompanying the source code -for this library. It contains a bunch of relevant usage examples. More docs in the future.. contributions + +For some more usage examples, please see the very reasonable test suite accompanying the source code +for this library. It contains a bunch of relevant usage examples. More docs in the future.. contributions *very* welcome! -Moving on, REXML is used as the XML parser by default. If you'd like, you can -configure Mediainfo to use Hpricot or Nokogiri instead using one of +Moving on, REXML is used as the XML parser by default. If you'd like, you can +configure Mediainfo to use Hpricot or Nokogiri instead using one of the following approaches: - * define the `MEDIAINFO_XML_PARSER` environment variable to be the - name of the parser as you'd pass to a :gem or :require call. - + * define the `MEDIAINFO_XML_PARSER` environment variable to be the + name of the parser as you'd pass to a :gem or :require call. + e.g. `export MEDIAINFO_XML_PARSER=nokogiri` - - * assign to Mediainfo.xml_parser after you've loaded the gem, + + * assign to Mediainfo.xml_parser after you've loaded the gem, following the same naming conventions mentioned previously. - + e.g. `Mediainfo.xml_parser = "hpricot"` - -Once you've got an instance setup, you can call numerous methods to get -a variety of information about a file. Some attributes may be present -for some files where others are not, but any supported attribute + +Once you've got an instance setup, you can call numerous methods to get +a variety of information about a file. Some attributes may be present +for some files where others are not, but any supported attribute should at least return `nil`. For a list of all possible attributes supported: - + Mediainfo.supported_attributes - + ## Requirements This requires at least the following version of the Mediainfo CLI: - + MediaInfo Command line, MediaInfoLib - v0.7.25 - -Previous versions of this gem(<= 0.5.1) worked against v0.7.11, which did not + +Previous versions of this gem(<= 0.5.1) worked against v0.7.11, which did not generate XML output, and is no longer supported. =end class Mediainfo extend Forwardable extend AttrReaders - + class Error < StandardError; end class ExecutionError < Error; end class IncompatibleVersionError < Error; end class UnknownVersionError < Error; end - + def self.delegate(method_name, stream_type = nil) if stream_type == :general def_delegator :"@#{stream_type}_stream", method_name @@ -100,48 +102,75 @@ def self.delegate(method_name, stream_type = nil) def_delegator :"@#{stream_type}_stream", method_name, "#{stream_type}_#{method_name}" end end - + def self.version @version ||= `#{version_command}`[/v([\d.]+)/, 1] end - + def self.version_command "#{path} --Version" end - + # AttrReaders depends on this. def self.supported_attributes; @supported_attributes ||= []; end - + SECTIONS = [:general, :video, :audio, :image, :menu, :text, :other] NON_GENERAL_SECTIONS = SECTIONS - [:general] - + attr_reader :streams - + + def file_access_scheme + if @full_filename.nil? + :none + elsif URI.regexp.match(@full_filename).nil? + nil + else + URI(@full_filename).scheme + end + end + # Size of source file as reported by File.size. # Returns nil if you haven't yet fired off the system command. - def size; File.size(@full_filename) if @full_filename; end + def size + return nil if @full_filename.nil? + + case file_access_scheme + when nil + File.size(@full_filename) + when /https?/ + if @http_metadata['content-range'] + @http_metadata['content-range'].split(%r(/)).last.to_i + else + @http_metadata['content-length'].to_i + end + when /file/ + File.size(URI.decode(URI(@full_filename).path)) + else + nil + end + end def other; @other_proxy ||= StreamProxy.new(self, :other); end def other?; streams.any? { |x| x.other? }; end - + class StreamProxy def initialize(mediainfo, stream_type) unless Mediainfo::SECTIONS.include? stream_type raise ArgumentError, "invalid stream_type: #{stream_type.inspect}" end - + @stream_type = stream_type @mediainfo = mediainfo @streams = @mediainfo.streams.select { |x| x.send("#{stream_type}?") } end - + def [](id); @streams[id]; end def count; @streams.size; end attr_reader :streams attr_reader :stream_type - + class SingleStreamAPIError < RuntimeError; end class NoStreamsForProxyError < NoMethodError; end - + def method_missing(m, *a, &b) if streams.size > 1 raise SingleStreamAPIError, "You cannot use the single stream, convenience API on a multi-stream file." @@ -154,83 +183,83 @@ def method_missing(m, *a, &b) end end end - + class Stream class InvalidStreamType < Mediainfo::Error; end - + def self.inherited(stream_type) stream_type.extend(AttrReaders) - + def stream_type.method_added(method_name) if stream_type = name[/[^:]+$/][/^(#{SECTIONS.map { |x| x.to_s.capitalize } * '|'})/] - stream_type.downcase! + stream_type.downcase! stream_type = stream_type.to_sym else raise "could not determine stream type, please report bug!" end - + Mediainfo.delegate(method_name, stream_type) end end - + def self.create(stream_type) raise ArgumentError, "need a stream_type, received #{stream_type.inspect}" if stream_type.nil? - + stream_class_name = "#{stream_type}Stream" - + if Mediainfo.const_defined?(stream_class_name) Mediainfo.const_get(stream_class_name).new(stream_type) else raise InvalidStreamType, "bad stream type: #{stream_type.inspect}" end end - + def initialize(stream_type) raise ArgumentError, "need a stream_type, received #{stream_type.inspect}" if stream_type.nil? - + @stream_type = stream_type.downcase.to_sym - - # TODO @parsed_response is not the best name anymore, but I'm leaving it - # alone to focus on refactoring the interface to the streams + + # TODO @parsed_response is not the best name anymore, but I'm leaving it + # alone to focus on refactoring the interface to the streams # before I refactor the attribute reader implementations. @parsed_response = { @stream_type => {} } end - + attr_reader :parsed_response - + def [](k); @parsed_response[@stream_type][k]; end def []=(k,v); @parsed_response[@stream_type][k] = v; end def other?; :other == @stream_type; end - + Mediainfo::SECTIONS.each { |t| define_method("#{t}?") { t == @stream_type } } end - + class GeneralStream < Stream mediainfo_attr_reader :codec_id, "Codec ID" - + mediainfo_duration_reader :duration - + mediainfo_attr_reader :format mediainfo_attr_reader :format_profile mediainfo_attr_reader :format_info mediainfo_attr_reader :overall_bit_rate mediainfo_attr_reader :writing_application mediainfo_attr_reader :writing_library - + mediainfo_date_reader :mastered_date mediainfo_date_reader :tagged_date mediainfo_date_reader :encoded_date end - + class VideoStream < Stream mediainfo_attr_reader :stream_id, "ID" - + mediainfo_duration_reader :duration - + mediainfo_attr_reader :stream_size mediainfo_attr_reader :bit_rate mediainfo_attr_reader :nominal_bit_rate - + mediainfo_attr_reader :bit_rate_mode def cbr?; video? and "Constant" == bit_rate_mode; end def vbr?; video? and not cbr?; end @@ -290,13 +319,13 @@ def frame_size; "#{width}x#{height}" if width or height; end mediainfo_date_reader :encoded_date mediainfo_date_reader :tagged_date - + mediainfo_attr_reader :standard end - + class AudioStream < Stream mediainfo_attr_reader :stream_id, "ID" - + mediainfo_duration_reader :duration def accurate_duration duration = 0 @@ -307,7 +336,7 @@ def accurate_duration end duration end - + mediainfo_attr_reader :sampling_rate def sample_rate return unless rate = sampling_rate_before_type_cast @@ -349,14 +378,14 @@ def mono?; 1 == channels; end mediainfo_date_reader :encoded_date mediainfo_date_reader :tagged_date end - + class ImageStream < Stream mediainfo_attr_reader :resolution mediainfo_attr_reader :format - + mediainfo_int_reader :width mediainfo_int_reader :height - + def frame_size; "#{width}x#{height}" if width or height; end end @@ -366,73 +395,79 @@ class TextStream < Stream mediainfo_attr_reader :codec_id, "Codec ID" mediainfo_attr_reader :codec_info, "Codec ID/Info" end - + class MenuStream < Stream mediainfo_attr_reader :stream_id, "ID" mediainfo_date_reader :encoded_date mediainfo_date_reader :tagged_date mediainfo_int_reader :delay end - + class OtherStream < Stream mediainfo_attr_reader :stream_id, "ID" mediainfo_attr_reader :type end - + Mediainfo::SECTIONS.each do |stream_type| class_eval %{ def #{stream_type}; @#{stream_type}_proxy ||= StreamProxy.new(self, :#{stream_type}); end def #{stream_type}?; streams.any? { |x| x.#{stream_type}? }; end }, __FILE__, __LINE__ end - + ### - + attr_reader :raw_response, :full_filename, :filename, :path, :escaped_full_filename - + ### - + def initialize(full_filename = nil) unless mediainfo_version raise UnknownVersionError, "Unable to determine mediainfo version. " + "We tried: #{self.class.version_command} " + - "Are you sure mediainfo is installed at #{self.class.path.inspect}? " + + "Are you sure mediainfo is installed at #{self.class.path.inspect}? " + "Set Mediainfo.path = /where/is/mediainfo if it is not in your PATH." end - + if mediainfo_version < "0.7.25" raise IncompatibleVersionError, "Your version of mediainfo, #{mediainfo_version}, " + "is not compatible with this gem. >= 0.7.25 required." end - + @streams = [] - - if full_filename + + @full_filename = full_filename + case file_access_scheme + when nil @full_filename = File.expand_path full_filename @path = File.dirname @full_filename @filename = File.basename @full_filename - + raise ArgumentError, "need a path to a video file, got nil" unless @full_filename raise ArgumentError, "need a path to a video file, #{@full_filename} does not exist" unless File.exist? @full_filename - - @escaped_full_filename = @full_filename.shell_escape_double_quotes - - self.raw_response = mediainfo! + when /https?/ + @full_filename = full_filename + @http_metadata = open(@full_filename, 'range' => 'bytes=0-0') { |io| io.meta } + else + @full_filename = full_filename end + @escaped_full_filename = @full_filename.shell_escape_double_quotes unless @full_filename.nil? + + self.raw_response = mediainfo! end - + def raw_response=(response) raise ArgumentError, "raw response is nil" if response.nil? @raw_response = response parse! @raw_response end - + class << self attr_accessor :path - + def load_xml_parser!(parser = xml_parser) begin gem parser @@ -442,18 +477,18 @@ def load_xml_parser!(parser = xml_parser) "your specified XML parser, #{parser.inspect}, could not be loaded: #{e}" end end - + attr_reader :xml_parser def xml_parser=(parser) load_xml_parser! parser @xml_parser = parser end end - + unless ENV["MEDIAINFO_XML_PARSER"].to_s.strip.empty? self.xml_parser = ENV["MEDIAINFO_XML_PARSER"] end - + def path; self.class.path; end def xml_parser; self.class.xml_parser; end @@ -468,21 +503,21 @@ def self.default_mediainfo_path end default_mediainfo_path! unless path - + def mediainfo_version; self.class.version; end - + attr_reader :last_command - + def inspect super.sub(/@raw_response=".+?", @/, %{@raw_response="...", @}) end - + private def mediainfo! @last_command = "#{path} #{@escaped_full_filename} --Output=XML" run_command! end - + def run_command! raw_response = `#{@last_command} 2>&1` unless $? == 0 @@ -491,14 +526,14 @@ def run_command! end raw_response end - + def parse! if xml_parser self.class.load_xml_parser! else require "rexml/document" end - + case xml_parser when "nokogiri" Nokogiri::XML(@raw_response).xpath("//track").each { |t| @@ -525,7 +560,7 @@ def parse! @streams << s } end - + SECTIONS.each do |section| default_target_stream = if send("#{section}?") send(section).streams.first @@ -535,7 +570,7 @@ def parse! instance_variable_set "@#{section}_stream", default_target_stream end end - + def key_for(attribute_node) attribute_node.name.downcase.gsub(/_+/, "_").gsub(/_s(\W|$)/, "s").strip end