mediafile 0.0.3

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
+ SHA1:
3
+ metadata.gz: 119b0a54c4c60f5422faa73912fefbcfd97390ec
4
+ data.tar.gz: b7d0d780c5a49fe57b7adbd9125723316d8bfa3b
5
+ SHA512:
6
+ metadata.gz: 9995f98f87546a394e5d26494442d1ecd3109a297d8e7596f36494ff41ff3b7eff4484fc23a17984a7ebdb42d65989271d4b5c6d702411632234b407ee72902d
7
+ data.tar.gz: 99e25a1163b071e326826a490b26e145b68fd67522b40ba591d2b8b1ff921fd37dc0fa856a3ad7d9d1d6cb51cc18a98d989f9fa85897924bc4c25cbc6c25c628
data/bin/music_cp ADDED
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+ # vim:et sw=2 ts=2
3
+ #
4
+
5
+ require 'fileutils'
6
+ require 'optparse'
7
+ require 'digest/md5'
8
+ require 'timeout'
9
+ require 'mediafile'
10
+
11
+ PROGNAME = File.basename($0)
12
+
13
+ def die(msg)
14
+ abort "#{PROGNAME}: #{msg}"
15
+ end
16
+
17
+ kill = me = now = false
18
+ files = []
19
+ dest = "."
20
+ verbose = false
21
+ progress = true
22
+ count = `grep -c '^processor' /proc/cpuinfo`.strip.to_i/2|1
23
+ transcode = { flac: :mp3, wav: :mp3 }
24
+ exclude_patterns = []
25
+ file_types = "{flac,mp3,MP3,FLAC,wav,WAV,m4a,M4A}"
26
+ opts = OptionParser.new do |opt|
27
+
28
+ opt.on("-f", "--file FILE|DIR", "File or directory to copy.",
29
+ "If given a directory, will grab all files within non-recursively") do |e|
30
+ e.split(",").each do |f|
31
+ if File.file? f
32
+ files << f
33
+ elsif File.directory? f
34
+ files.concat Dir.glob( f + "/*.#{file_types}")
35
+ else
36
+ warn "#{f} is not a file or a directory!"
37
+ end
38
+ end
39
+ end
40
+ opt.on("-r", "--recursive DIR", "Directory to recursively scan and copy") do |r|
41
+ raise "Directory '#{r}' does not exist" unless File.directory? r
42
+ files.concat Dir.glob( r + "/**/*.#{file_types}")
43
+ end
44
+ opt.on("-d", "--destination PATH", "Where to copy file to. Default: '#{dest}'",
45
+ "Will be created if it doesn't exist.") do |d|
46
+ dest = d
47
+ end
48
+ opt.on("--transcode <from=to[,from1=to1]>", "A comma-seperated series of name=value pairs.",
49
+ "Default is #{transcode.to_a.map{|i| i.join("=")}.join(",")}") do |fmt|
50
+ kill = true
51
+ transcode = Hash[ *fmt.split(",").map{|e| e.split("=").map{ |t| t.downcase.to_sym } }.flatten ]
52
+ end
53
+ opt.on("-c", "--copy", "Turn off transcoding.") do
54
+ transcode = {}
55
+ me = true
56
+ end
57
+ opt.on("--[no-]progress", "Set show progress true/false. Default is #{progress}") do |t|
58
+ progress = t
59
+ end
60
+ opt.on("--exclude PATTERN", "-x PATTERN", String, "Exclude files that match the given pattern.",
61
+ "Can specify more than once, file is excluded if any pattern matches") do |p|
62
+ exclude_patterns << p
63
+ end
64
+ opt.on("-v", "--[no-]verbose", "Be verbose") do |v|
65
+ verbose = v
66
+ end
67
+ opt.on("-t", "--threads NUM",
68
+ "Number of threads to spawn, useful for transcoding. Default: #{count}" ) do |n|
69
+ count = n.to_i
70
+ end
71
+ opt.on_tail("-h", "--help", "Show this message") do
72
+ warn opt
73
+ exit
74
+ end
75
+ begin
76
+ opt.parse!
77
+ if kill and me
78
+ raise OptionParser::InvalidOption.new("--copy and --transcode are conflicting")
79
+ end
80
+ rescue OptionParser::InvalidOption
81
+ warn "#{PROGNAME}: #{$!}"
82
+ die opt
83
+ end
84
+ end
85
+
86
+ files = files.uniq.sort
87
+ if exclude_patterns.any?
88
+ pattern = Regexp.new(exclude_patterns.join("|"))
89
+ files.delete_if { |el| pattern.match el }
90
+ end
91
+ if files.empty?
92
+ warn "No file specified, exiting"
93
+ warn opts
94
+ exit
95
+ end
96
+ puts "Full list of files to transfer to #{dest}:"
97
+ files.each { |l| puts "\t"+l }
98
+ puts "#{files.count} files total"
99
+ puts "The following transcode table will be used:"
100
+ puts transcode.any? ? transcode : 'none'
101
+ puts "Do you wish procede? (Y/n)"
102
+ y = gets
103
+ if /n/i.match(y)
104
+ puts "User cancel."
105
+ exit
106
+ end
107
+ puts "Begin copy to #{dest}"
108
+ copier = MediaFile::BulkMediaCopy.new(files, destination_root: dest, verbose: verbose, transcode: transcode, progress: progress)
109
+
110
+ copier.run count
111
+
112
+ puts "Complete."
@@ -0,0 +1,148 @@
1
+ module MediaFile; class BulkMediaCopy
2
+ def initialize( source, destination_root: ".", verbose: false, progress: false, transcode: {} )
3
+ source = case source
4
+ when String
5
+ [source]
6
+ when Array
7
+ source
8
+ else
9
+ raise "Bad value for required first arg 'source': '#{source.class}'. Should be String or Array."
10
+ end
11
+
12
+ @copies = Hash.new { |h,k| h[k] = [] }
13
+ @destination_root = destination_root
14
+ @verbose = verbose
15
+ @progress = progress
16
+ @work = source.map { |s|
17
+ MediaFile.new(s,
18
+ base_dir: @destination_root,
19
+ verbose: @verbose,
20
+
21
+ printer: proc{ |msg| self.safe_print( msg ) }
22
+ )
23
+ }
24
+ @width = [@work.count.to_s.size, 2].max
25
+ @name_width = @work.max{ |a,b| a.name.size <=> b.name.size }.name.size
26
+ @transcode = transcode
27
+ @count = 0
28
+ @failed = []
29
+ end
30
+
31
+ def run(max=4)
32
+ puts "%#{@width + 8}s, %#{@width + 8}s,%#{@width + 8}s, %-#{@name_width}s => Destination Path" % [
33
+ "Remaining",
34
+ "Workers",
35
+ "Complete",
36
+ "File Name"
37
+ ]
38
+ puts "%#{@width}d ( 100%%), %#{@width}d (%4.1f%%), %#{@width}d ( 0.0%%)" % [
39
+ @work.count,
40
+ 0,
41
+ 0,
42
+ 0
43
+ ]
44
+ max > 1 ? mcopy(max) : scopy
45
+ dupes = @copies.select{ |k,a| a.size > 1 }
46
+ if dupes.any?
47
+ puts "dupes"
48
+ require 'pp'
49
+ pp dupes
50
+ end
51
+ if @failed.any?
52
+ puts "Some files timed out"
53
+ @failed.each { |f| puts f.to_s }
54
+ end
55
+ end
56
+
57
+ def safe_print(message='')
58
+ locked {
59
+ puts block_given? ? yield : message
60
+ }
61
+ end
62
+
63
+ private
64
+
65
+ def locked
66
+ if @semaphore
67
+ @semaphore.synchronize {
68
+ yield
69
+ }
70
+ else
71
+ yield
72
+ end
73
+ end
74
+
75
+ def mcopy(max)
76
+ raise "Argument must repond to :times" unless max.respond_to? :times
77
+ raise "I haven't any work..." unless @work
78
+ require 'thread'
79
+ @semaphore = Mutex.new
80
+ queue = Queue.new
81
+ @work.each { |s| queue << s }
82
+ threads = []
83
+ max.times do
84
+ threads << Thread.new do
85
+ while ( s = queue.pop(true) rescue nil)
86
+ copy s
87
+ end
88
+ end
89
+ end
90
+ threads.each { |t| t.join }
91
+ @semaphore = nil
92
+ end
93
+
94
+ def scopy
95
+ raise "I haven't any work..." unless @work
96
+ @work.each do |f|
97
+ copy f
98
+ end
99
+ end
100
+
101
+ def copy(mediafile)
102
+ dest = mediafile.out_path transcode_table: @transcode
103
+ locked {
104
+ return unless copy_check? mediafile.source_md5, mediafile.source, dest
105
+ }
106
+
107
+ err = false
108
+ begin
109
+ mediafile.copy transcode_table: @transcode
110
+ rescue Timeout::Error
111
+ @failed << mediafile
112
+ err = true
113
+ end
114
+
115
+ locked {
116
+ @count += 1
117
+ if @progress
118
+ left = @work.count - @count
119
+ left_perc = left == 0 ? left : left.to_f / @work.count * 100
120
+ cur = @copies.count - @count
121
+ cur_perc = cur == 0 ? cur : cur.to_f / left * 100 # @work.count * 100
122
+ c = cur_perc == 100
123
+ finished = @count.to_f / @work.count * 100
124
+ f = finished == 100.0
125
+ puts "%#{@width}d (%4.1f%%), %#{@width}d (%4.#{c ? 0 : 1}f%%), %#{@width}d (%4.#{f ? 0 : 1}f%%) %-#{@name_width}s => %-s" % [
126
+ left,
127
+ left_perc,
128
+ cur,
129
+ cur_perc,
130
+ @count,
131
+ finished,
132
+ (mediafile.name + (err ? " **" : "") ),
133
+ mediafile.out_path(transcode_table:@transcode)
134
+ ]
135
+ end
136
+ }
137
+
138
+ end
139
+
140
+ def copy_check?(md5,name,dest)
141
+ # if multi-threaded, need to lock before calling
142
+ @copies[md5] << "#{name} => #{dest}"
143
+ # return true if this is the only one
144
+ @copies[md5].count == 1
145
+ end
146
+
147
+ end; end
148
+
@@ -0,0 +1,284 @@
1
+ #!/usr/bin/env ruby
2
+ # vim:et sw=2 ts=2
3
+
4
+ module MediaFile; class MediaFile
5
+
6
+ attr_reader :source, :type, :name, :base_dir
7
+
8
+ def initialize(path, verbose: false, base_dir: '.', printer: proc {|msg| puts msg})
9
+ @source = path
10
+ @base_dir = base_dir
11
+ @verbose = verbose
12
+ @name = File.basename( @source, File.extname( @source ) )
13
+ @type = path[/(\w+)$/].downcase.to_sym
14
+ @printer = printer
15
+ @destinations = Hash.new{ |k,v| k[v] = {} }
16
+ end
17
+
18
+ def source_md5
19
+ @source_md5 ||= Digest::MD5.hexdigest( @source )
20
+ end
21
+
22
+ def out_path(base_dir: @base_dir, transcode_table: {})
23
+ @destinations[base_dir][transcode_table] ||= File.join(
24
+ base_dir,
25
+ relative_path,
26
+ new_file_name,
27
+ ) << ".#{transcode_table[@type] || @type}"
28
+ end
29
+
30
+ def copy(dest: @base_dir, transcode_table: {})
31
+ destination = out_path base_dir: dest, transcode_table: transcode_table
32
+ temp_dest = tmp_path base_dir: dest, transcode_table: transcode_table
33
+ unless File.exists? destination
34
+ FileUtils.mkdir_p File.dirname destination
35
+ begin
36
+ if transcode_table.has_key? @type
37
+ transcode transcode_table, temp_dest
38
+ else
39
+ FileUtils.cp @source, temp_dest
40
+ end
41
+ FileUtils.mv temp_dest, destination
42
+ rescue => e
43
+ FileUtils.rm temp_dest if File.exists? temp_dest
44
+ raise e
45
+ end
46
+ end
47
+ end
48
+
49
+ def printit(msg)
50
+ @printer.call msg
51
+ end
52
+
53
+ def to_s
54
+ "#{@source}"
55
+ end
56
+
57
+ def self.tags(*args)
58
+ args.each do |arg|
59
+ define_method arg do
60
+ read_tags
61
+ instance_variable_get "@#{arg}"
62
+ end
63
+ end
64
+ end
65
+
66
+ tags :album, :artist, :album_artist, :title, :genre, :year, :track, :comment, :disc_number, :disc_total
67
+
68
+ private
69
+
70
+ def set_decoder()
71
+ case @type
72
+ when :flac
73
+ %W{flac -c -s -d #{@source}}
74
+ when :mp3
75
+ #%W{lame --decode #{@source} -}
76
+ %W{sox #{@source} -t wav -}
77
+ when :m4a
78
+ %W{ffmpeg -i #{@source} -f wav -}
79
+ when :wav
80
+ %W{cat #{@source}}
81
+ else
82
+ raise "Unknown type '#{@type}'. Cannot set decoder"
83
+ end
84
+ end
85
+
86
+ def set_encoder(to,destination)
87
+ comment = "; Transcoded by MediaFile on #{Time.now}"
88
+ case to
89
+ when :flac
90
+ raise "Please don't transcode to flac. It is broken right now"
91
+ %W{flac -7 -V -s -o #{destination}} +
92
+ (@artist ? ["-T", "artist=#{@artist}"] : [] ) +
93
+ (@title ? ["-T", "title=#{@title}"] : [] ) +
94
+ (@album ? ["-T", "album=#{@album}"] : [] ) +
95
+ (@track > 0 ? ["-T", "tracknumber=#{@track}"] : [] ) +
96
+ (@year ? ["-T", "date=#{@year}"] : [] ) +
97
+ (@genre ? ["-T", "genre=#{@genre}"] : [] ) +
98
+ ["-T", "comment=" + @comment + comment ] +
99
+ (@album_artist ? ["-T", "albumartist=#{@album_artist}"] : [] ) +
100
+ (@disc_number ? ["-T", "discnumber=#{@disc_number}"] : [] ) +
101
+ ["-"]
102
+ when :mp3
103
+ %W{/usr/bin/lame --quiet --preset extreme -h --add-id3v2 --id3v2-only} +
104
+ (@title ? ["--tt", @title] : [] ) +
105
+ (@artist ? ["--ta", @artist]: [] ) +
106
+ (@album ? ["--tl", @album] : [] ) +
107
+ (@track > 0 ? ["--tn", @track.to_s]: [] ) +
108
+ (@year ? ["--ty", @year.to_s ] : [] ) +
109
+ (@genre ? ["--tg", @genre ]: [] ) +
110
+ ["--tc", @comment + comment ] +
111
+ (@album_artist ? ["--tv", "TPE2=#{@album_artist}"] : [] ) +
112
+ (@disc_number ? ["--tv", "TPOS=#{@disc_number}"] : [] ) +
113
+ ["-", destination]
114
+ when :wav
115
+ %W{dd of=#{destination}}
116
+ else
117
+ raise "Unknown target '#{to}'. Cannot set encoder"
118
+ end
119
+ end
120
+
121
+ def transcode(trans , destination)
122
+ to = trans[@type]
123
+ printit "Attempting to transcode to the same format #{@source} from #{@type} to #{to}" if to == @type
124
+ FileUtils.mkdir_p File.dirname destination
125
+
126
+ decoder = set_decoder
127
+
128
+ encoder = set_encoder(to, destination)
129
+
130
+ printit "Decoder: '#{decoder.join(' ')}'\nEncoder: '#{encoder.join(' ')}'" if @verbose
131
+
132
+ pipes = Hash[[:encoder,:decoder].zip IO.pipe]
133
+ #readable, writeable = IO.pipe
134
+ pids = {
135
+ spawn(*decoder, :out=>pipes[:decoder], :err=>"/dev/null") => :decoder,
136
+ spawn(*encoder, :in =>pipes[:encoder], :err=>"/dev/null") => :encoder,
137
+ }
138
+ tpids = pids.keys
139
+ err = []
140
+ begin
141
+ Timeout::timeout(60 * ( File.size(@source) / 1024 / 1024 /2 ) ) {
142
+ #Timeout::timeout(3 ) {
143
+ while tpids.any? do
144
+ sleep 0.2
145
+ tpids.delete_if do |pid|
146
+ ret = false
147
+ p, stat = Process.wait2 pid, Process::WNOHANG
148
+ if stat
149
+ pipes[pids[pid]].close unless pipes[pids[pid]].closed?
150
+ ret = true
151
+ end
152
+ if stat and stat.exitstatus and stat.exitstatus != 0
153
+ err << [ pids[pid], stat ]
154
+ end
155
+ ret
156
+ end
157
+ end
158
+ }
159
+ rescue Timeout::Error
160
+ printit "Timeout exceeded!\n" << tpids.map { |p|
161
+ Process.kill 15, p
162
+ Process.kill 9, p
163
+ "#{p} #{Process.wait2( p )[1]}"
164
+ }.join(", ")
165
+ FileUtils.rm [destination]
166
+ raise
167
+ end
168
+ if err.any?
169
+ printit "Error with #{err.map{|it,stat| "#{it} EOT:#{stat.exitstatus} #{stat}" }.join(" and ")}"
170
+ #raise "Error #{@source} #{err}"
171
+ end
172
+ end
173
+
174
+ # directory names cannot end with a '.'
175
+ # it breaks windows (really!)
176
+
177
+ def relative_path
178
+ return @relpath if @relpath
179
+ read_tags
180
+ dest = File.join(
181
+ [(@album_artist||"UNKNOWN"), (@album||"UNKNOWN")].map { |word|
182
+ word.gsub(/^\.+|\.+$/,"").gsub(/\//,"_")
183
+ }
184
+ )
185
+ bool=true
186
+ @relpath = dest.gsub(/\s/,"_").gsub(/[,:)\]\[('"@$^*<>?!]/,"").gsub(/_[&]_/,"_and_").split('').map{ |c|
187
+ b = bool; bool = c.match('/|_'); b ? c.capitalize : c
188
+ }.join('').gsub(/__+/,'_')
189
+ end
190
+
191
+ def new_file_name
192
+ # this doesn't include the extension.
193
+ @newname ||= (
194
+ read_tags
195
+ bool=true
196
+ file= ( case
197
+ when (@disc_number && (@track > 0) && @title) && !(@disc_total && @disc_total == 1)
198
+ "%1d_%02d-" % [@disc_number, @track] + @title
199
+ when (@track > 0 && @title)
200
+ "%02d-" % @track + @title
201
+ when @title
202
+ @title
203
+ else
204
+ @name
205
+ end).gsub(
206
+ /^\.+|\.+$/,""
207
+ ).gsub(
208
+ /\//,"_"
209
+ ).gsub(
210
+ /\s/,"_"
211
+ ).gsub(
212
+ /[,:)\]\[('"@$^*<>?!]/,""
213
+ ).gsub(
214
+ /_[&]_/,"_and_"
215
+ ).split('').map{ |c|
216
+ b = bool; bool = c.match('/|_'); b ? c.capitalize : c
217
+ }.join('')
218
+ )
219
+ end
220
+
221
+ def tmp_file_name
222
+ "." + new_file_name
223
+ end
224
+
225
+ def tmp_path(base_dir: @base_dir, transcode_table: {})
226
+ File.join(
227
+ base_dir,
228
+ relative_path,
229
+ tmp_file_name,
230
+ ) << ".#{transcode_table[@type] || @type}"
231
+ end
232
+
233
+ def read_tags
234
+ return if @red
235
+ @album = @artist= @title = @genre = @year = nil
236
+ @track = 0
237
+ @comment = ""
238
+ TagLib::FileRef.open(@source) do |file|
239
+ unless file.null?
240
+ tag = file.tag
241
+ @album = tag.album if tag.album
242
+ @artist = tag.artist if tag.artist
243
+ @title = tag.title if tag.title
244
+ @genre = tag.genre if tag.genre
245
+ @comment= tag.comment if tag.comment
246
+ @track = tag.track if tag.track
247
+ @year = tag.year if tag.year
248
+ end
249
+ end
250
+ @album_artist = @artist
251
+ case @type
252
+ when :m4a
253
+ TagLib::MP4::File.open(@source) do |file|
254
+ @disc_number = file.tag.item_list_map["disk"].to_int_pair[0]
255
+ end
256
+ when :flac
257
+ TagLib::FLAC::File.open(@source) do |file|
258
+ if tag = file.xiph_comment
259
+ [
260
+ [:album_artist, ['ALBUMARTIST', 'ALBUM ARTIST', 'ALBUM_ARTIST'], :to_s ],
261
+ [:disc_number, ['DISCNUMBER'], :to_i ],
262
+ [:disc_total, ['DISCTOTAL'], :to_i ]
263
+ ].each do |field,list,func|
264
+ val = list.collect{ |i| tag.field_list_map[i] }.select{|i| i }.first
265
+ instance_variable_set("@#{field}", val[0].send(func)) if val
266
+ end
267
+ end
268
+ end
269
+ when :mp3
270
+ TagLib::MPEG::File.open(@source) do |file|
271
+ tag = file.id3v2_tag
272
+ if tag
273
+ [['TPE2', :@album_artist, :to_s], ['TPOS', :@disc_number, :to_i]].each do |t,v,m|
274
+ if tag.frame_list(t).first and tag.frame_list(t).first.to_s.size > 0
275
+ instance_variable_set( v, "#{tag.frame_list(t).first}".send( m.to_sym) )
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+ @red = true
282
+ end
283
+ end; end
284
+
@@ -0,0 +1,3 @@
1
+ module MediaFile
2
+ VERSION = "0.0.3" unless defined?(::MediaFile::VERSION)
3
+ end
data/lib/mediafile.rb ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # vim:et sw=2 ts=2
3
+
4
+ require 'fileutils'
5
+ require 'digest/md5'
6
+ require 'timeout'
7
+ require 'taglib'
8
+ require 'mediafile/version'
9
+
10
+ module MediaFile
11
+
12
+ autoload :MediaFile, 'mediafile/mediafile.rb'
13
+ autoload :BulkMediaCopy, 'mediafile/bulkmediacopy.rb'
14
+
15
+ end
metadata ADDED
@@ -0,0 +1,72 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mediafile
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.3
5
+ platform: ruby
6
+ authors:
7
+ - Jeff Harvey-Smith
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2014-04-07 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: taglib-ruby
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ~>
18
+ - !ruby/object:Gem::Version
19
+ version: '0.6'
20
+ - - '>='
21
+ - !ruby/object:Gem::Version
22
+ version: 0.6.0
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ version: '0.6'
30
+ - - '>='
31
+ - !ruby/object:Gem::Version
32
+ version: 0.6.0
33
+ description: Parse media file metadata, copy or transcode mediafiles.
34
+ email:
35
+ - jharveysmith@gmail.com
36
+ executables:
37
+ - music_cp
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - ./bin/music_cp
42
+ - ./lib/mediafile.rb
43
+ - ./lib/mediafile/bulkmediacopy.rb
44
+ - ./lib/mediafile/mediafile.rb
45
+ - ./lib/mediafile/version.rb
46
+ - bin/music_cp
47
+ homepage: https://github.com/seginoviax/mediafile
48
+ licenses:
49
+ - MIT
50
+ metadata: {}
51
+ post_install_message:
52
+ rdoc_options: []
53
+ require_paths:
54
+ - lib
55
+ required_ruby_version: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ~>
58
+ - !ruby/object:Gem::Version
59
+ version: '2'
60
+ required_rubygems_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - '>='
63
+ - !ruby/object:Gem::Version
64
+ version: '0'
65
+ requirements: []
66
+ rubyforge_project:
67
+ rubygems_version: 2.2.2
68
+ signing_key:
69
+ specification_version: 4
70
+ summary: Parse media file metadata.
71
+ test_files: []
72
+ has_rdoc: