diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 9220c02..d7dc40f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,76 +1,52 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2020-12-30 04:27:41 UTC using RuboCop version 1.7.0. +# on 2021-01-03 08:54:57 UTC using RuboCop version 1.7.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 2 +# Offense count: 1 # Configuration parameters: AllowSafeAssignment. Lint/AssignmentInCondition: Exclude: - - 'lib/audioinfo.rb' - 'lib/audioinfo/album.rb' -# Offense count: 1 -Lint/RescueException: - Exclude: - - 'lib/audioinfo.rb' - -# Offense count: 1 -Lint/ShadowedException: - Exclude: - - 'lib/audioinfo.rb' - -# Offense count: 2 -Lint/ShadowingOuterLocalVariable: - Exclude: - - 'lib/audioinfo.rb' - -# Offense count: 1 -# Configuration parameters: AllowComments. -Lint/SuppressedException: - Exclude: - - 'lib/audioinfo.rb' - -# Offense count: 5 +# Offense count: 3 Lint/UselessAssignment: Exclude: - - 'lib/audioinfo.rb' - 'lib/audioinfo/mpcinfo.rb' -# Offense count: 7 +# Offense count: 10 # Configuration parameters: IgnoredMethods, CountRepeatedAttributes. Metrics/AbcSize: - Max: 177 + Max: 166 # Offense count: 2 # Configuration parameters: CountComments, CountAsOne. Metrics/ClassLength: - Max: 322 + Max: 128 # Offense count: 4 # Configuration parameters: IgnoredMethods. Metrics/CyclomaticComplexity: - Max: 29 + Max: 14 -# Offense count: 8 +# Offense count: 16 # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. Metrics/MethodLength: - Max: 117 + Max: 70 -# Offense count: 4 +# Offense count: 3 # Configuration parameters: IgnoredMethods. Metrics/PerceivedComplexity: - Max: 24 + Max: 16 -# Offense count: 6 +# Offense count: 5 # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. # AllowedNames: at, by, db, id, in, io, ip, of, on, os, pp, to Naming/MethodParameterName: Exclude: - - 'lib/audioinfo.rb' - 'lib/audioinfo/mpcinfo.rb' # Offense count: 1 @@ -84,15 +60,24 @@ Naming/PredicateName: - 'spec/**/*' - 'lib/audioinfo.rb' -# Offense count: 4 +# Offense count: 13 Style/Documentation: Exclude: - 'spec/**/*' - 'test/**/*' - 'lib/audioinfo.rb' - 'lib/audioinfo/album.rb' + - 'lib/audioinfo/ape.rb' + - 'lib/audioinfo/audio_file.rb' - 'lib/audioinfo/case_insensitive_hash.rb' + - 'lib/audioinfo/flac.rb' + - 'lib/audioinfo/mp3.rb' + - 'lib/audioinfo/mp4.rb' + - 'lib/audioinfo/mpc.rb' - 'lib/audioinfo/mpcinfo.rb' + - 'lib/audioinfo/ogg.rb' + - 'lib/audioinfo/wav.rb' + - 'lib/audioinfo/wma.rb' # Offense count: 4 # Configuration parameters: MaxUnannotatedPlaceholdersAllowed. @@ -100,11 +85,10 @@ Style/Documentation: Style/FormatStringToken: EnforcedStyle: unannotated -# Offense count: 8 +# Offense count: 9 # Configuration parameters: MinBodyLength. Style/GuardClause: Exclude: - - 'lib/audioinfo.rb' - 'lib/audioinfo/album.rb' - 'lib/audioinfo/mpcinfo.rb' diff --git a/Gemfile.lock b/Gemfile.lock index b01f39e..a889071 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -59,6 +59,7 @@ GEM wavefile (0.6.0) PLATFORMS + ruby universal-java-1.8 x86_64-darwin-20 x86_64-linux diff --git a/lib/audioinfo.rb b/lib/audioinfo.rb index 3604a8a..b00be56 100644 --- a/lib/audioinfo.rb +++ b/lib/audioinfo.rb @@ -2,38 +2,19 @@ require 'stringio' -require 'mp3info' -require 'ogginfo' -require 'wmainfo' -require 'mp4info' -require 'flacinfo' -require 'apetag' -require 'wavefile' - -$LOAD_PATH << __dir__ - +require 'audioinfo/audio_file' +require 'audioinfo/mp3' require 'audioinfo/mpcinfo' -require 'audioinfo/case_insensitive_hash' +require 'audioinfo/ogg' require 'audioinfo/version' +require 'audioinfo/wma' +require 'audioinfo/wav' +require 'audioinfo/mp4' +require 'audioinfo/flac' class AudioInfoError < StandardError; end -class AudioInfo - MUSICBRAINZ_FIELDS = { - 'trmid' => 'TRM Id', - 'artistid' => 'Artist Id', - 'albumid' => 'Album Id', - 'albumtype' => 'Album Type', - 'albumstatus' => 'Album Status', - 'albumartistid' => 'Album Artist Id', - 'sortname' => 'Sort Name', - 'trackid' => 'Track Id' - }.freeze - - SUPPORTED_EXTENSIONS = %w[mp3 ogg opus spx mpc wma mp4 aac m4a flac wav].freeze - - attr_reader :path, :extension, :musicbrainz_infos, :tracknum, :bitrate, :vbr, :artist, :album, :title, :length, :date - +module AudioInfo # Part of testing API - you should not use this directly attr_reader :info @@ -62,363 +43,29 @@ def self.is_audio_file?(path) end # open the file with path +fn+ - def initialize(filename, extension = nil) + def self.new(filename, extension = nil) raise(AudioInfoError, 'path is nil') if filename.nil? - @path = filename - ext = File.extname(@path) - @extension = extension || (ext && ext[1..].downcase) - raise(AudioInfoError, 'cannot find extension') if @extension.empty? - - @musicbrainz_infos = {} - - begin - case @extension - when 'mp3' - @info = Mp3Info.new(filename) - default_tag_fill - # "TXXX"=> - # ["MusicBrainz TRM Id\000", - # "MusicBrainz Artist Id\000aba64937-3334-4c65-90a1-4e6b9d4d7ada", - # "MusicBrainz Album Id\000e1a223c1-cbc2-427f-a192-5d22fefd7c4c", - # "MusicBrainz Album Type\000album", - # "MusicBrainz Album Status\000official", - # "MusicBrainz Album Artist Id\000"] - - if (arr = @info.tag2['TXXX']).is_a?(Array) - fields = MUSICBRAINZ_FIELDS.invert - arr.each do |val| - if val =~ /^MusicBrainz (.+)\000(.*)$/ - short_name = fields[Regexp.last_match(1)] - @musicbrainz_infos[short_name] = Regexp.last_match(2).gsub("\xEF\xBB\xBF".force_encoding('UTF-8'), '') - end - end - end - @bitrate = @info.bitrate - i = @info.tag.tracknum - @tracknum = (i.is_a?(Array) ? i.last : i).to_i - @length = @info.length.to_i - @date = @info.tag['date'] - @vbr = @info.vbr - @info.close - - when 'ogg', 'opus', 'spx' - @info = OggInfo.new(filename) - default_fill_musicbrainz_fields - default_tag_fill - @bitrate = @info.bitrate / 1000 - @tracknum = @info.tag.tracknumber.to_i - @length = @info.length.to_i - @date = @info.tag['date'] - @vbr = true - @info.close - - when 'mpc' - fill_ape_tag(filename) - mpc_info = MpcInfo.new(filename) - @bitrate = mpc_info.infos['bitrate'] / 1000 - @length = mpc_info.infos['length'] + ext = File.extname(filename) - when 'ape' - fill_ape_tag(filename) + extension ||= (ext && ext[1..].downcase) - when 'wma' - @info = WmaInfo.new(filename, encoding: 'utf-8') - @artist = @info.tags['Author'] - @album = @info.tags['AlbumTitle'] - @title = @info.tags['Title'] - @tracknum = @info.tags['TrackNumber'].to_i - @date = @info.tags['Year'] - @bitrate = @info.info['bitrate'] - @length = @info.info['playtime_seconds'] - MUSICBRAINZ_FIELDS.each do |key, original_key| - @musicbrainz_infos[key] = - @info.info["MusicBrainz/#{original_key.tr(' ', '')}"] || - @info.info["MusicBrainz/#{original_key}"] - end - - when 'mp4', 'aac', 'm4a' - @extension = 'mp4' - @info = MP4Info.open(filename) - @artist = @info.ART - @album = @info.ALB - @title = @info.NAM - @tracknum = (t = @info.TRKN) ? t.first : 0 - @date = @info.DAY - @bitrate = @info.BITRATE - @length = @info.SECS - mapping = MUSICBRAINZ_FIELDS.invert - - faad_info(filename).scan(/^MusicBrainz (.+): (.+)$/) do |match| - name, value = match - key = mapping[name] - next unless key - - @musicbrainz_infos[key] = value.strip.gsub("\u0000", '') - end - - when 'flac' - @info = FlacInfo.new(filename) - # Unfortunately, FlacInfo doesn't allow us to fiddle inside - # their class, so we have to brute force it. Any other - # solution (e.g. creating another abstraction or getting - # methods) lands up being more messy and brittle. - @info.instance_variable_set('@tags', CaseInsensitiveHash.new(@info.tags)) - - get_tag = proc do |name| - if t = @info.tags[name] - t.dup.force_encoding('utf-8') - end - end - - @artist = get_tag.call('artist') - @album = get_tag.call('album') - @title = get_tag.call('title') - @tracknum = @info.tags['tracknumber'].to_i - @date = get_tag.call('date') - @bitrate = 0 - @length = @info.streaminfo['total_samples'] / @info.streaminfo['samplerate'].to_f - @bitrate = File.size(filename).to_f * 8 / @length / 1024 if @length.positive? - @info.tags.each do |tagname, _tagvalue| - next unless tagname =~ /^musicbrainz_(.+)$/ - - @musicbrainz_infos[Regexp.last_match(1)] = get_tag.call(tagname) - end - @musicbrainz_infos['trmid'] = @info.tags['musicip_puid'] - # default_fill_musicbrainz_fields - - when 'wav' - @info = WaveFile::Reader.info(filename) - @length = @info.duration.hours * 3600 + @info.duration.minutes * 60 + @info.duration.seconds + - @info.duration.milliseconds * 0.001 - @bitrate = File.size(filename) * 8 / @length / 1024 + raise(AudioInfoError, 'cannot find extension') if extension.empty? + klass = + case extension + when 'mp3' then Mp3 + when 'ogg', 'opus', 'spx' then Ogg + when 'mpc' then Mpc + when 'ape' then Ape + when 'wma' then Wma + when 'mp4', 'aac', 'm4a' then Mp4 + when 'flac' then Flac + when 'wav' then Wav else raise(AudioInfoError, "unsupported extension '.#{@extension}'") end - @tracknum = nil if @tracknum&.zero? - - @musicbrainz_infos.delete_if { |_k, v| v.nil? } - @hash = { 'artist' => @artist, - 'album' => @album, - 'title' => @title, - 'tracknum' => @tracknum, - 'date' => @date, - 'length' => @length, - 'bitrate' => @bitrate } - rescue StandardError, Mp3InfoError, OggInfoError, ApeTagError => e - raise AudioInfoError, e.to_s, e.backtrace - end - - @needs_commit = false - end - - # set the title of the file - def title=(v) - if @title != v - @needs_commit = true - @title = v - end - end - - # set the artist of the file - def artist=(v) - if @artist != v - @needs_commit = true - @artist = v - end - end - - # set the album of the file - def album=(v) - if @album != v - @needs_commit = true - @album = v - end - end - - # set the track number of the file - def tracknum=(v) - v = v.to_i - if @tracknum != v - @needs_commit = true - @tracknum = v - end - end - - def picture=(filepath) - if @picture != filepath - @needs_commit = true - @picture = filepath - end - end - - # hash-like access to tag - def [](key) - @hash[key] - end - - # convert tags to hash - def to_h - @hash - end - - # close the file and commits changes to disk - def close - if @needs_commit - case @info - when Mp3Info - Mp3Info.open(@path) do |info| - info.tag.artist = @artist - info.tag.title = @title - info.tag.album = @album - info.tag.tracknum = @tracknum - if @picture - info.tag2.remove_pictures - info.tag2.add_picture(File.binread(@picture)) - end - end - when OggInfo - OggInfo.open(@path) do |ogg| - { 'artist' => @artist, - 'album' => @album, - 'title' => @title, - 'tracknumber' => @tracknum }.each do |k, v| - ogg.tag[k] = v.to_s - end - ogg.picture = @picture if @picture - end - - when ApeTag - ape = ApeTag.new(@path) - ape.update do |fields| - fields['Artist'] = @artist - fields['Album'] = @album - fields['Title'] = @title - fields['Track'] = @tracknum.to_s - end - else - have_metaflac = system('which metaflac > /dev/null') - have_ffmpeg = system('which ffmpeg > /dev/null') - if have_metaflac && @info.is_a?(FlacInfo) - tags = { 'ARTIST' => @artist, - 'ALBUM' => @album, - 'TITLE' => @title, - 'TRACKNUMBER' => @tracknum }.inject([]) do |tags, (key, value)| - tags + ['--set-tag', "#{key}=#{value}"] - end - tag_with_shell_command('metaflac', '--remove-all', :src) - tag_with_shell_command('metaflac', tags, :src) - elsif have_ffmpeg - tags = { 'artist' => @artist, - 'album' => @album, - 'title' => @title }.inject([]) do |tags, (key, value)| - tags + ['-metadata', "#{key}=#{value}"] - end - tag_with_shell_command('ffmpeg', '-y', '-i', :src, '-loglevel', 'quiet', tags, :dst) - else - raise(AudioInfoError, 'implement me') - end - end - end - @needs_commit - end - # {"musicbrainz_albumstatus"=>"official", - # "artist"=>"Jill Scott", - # "replaygain_track_gain"=>"-3.29 dB", - # "tracknumber"=>"1", - # "title"=>"A long walk (A touch of Jazz Mix)..Jazzanova Love Beats...", - # "musicbrainz_sortname"=>"Scott, Jill", - # "musicbrainz_artistid"=>"b1fb6a18-1626-4011-80fb-eaf83dfebcb6", - # "musicbrainz_albumid"=>"cb2ad8c7-4a02-4e46-ae9a-c7c2463c7235", - # "replaygain_track_peak"=>"0.82040048", - # "musicbrainz_albumtype"=>"compilation", - # "album"=>"...Mixing (Jazzanova)", - # "musicbrainz_trmid"=>"1ecec0a6-c7c3-4179-abea-ef12dabc7cbd", - # "musicbrainz_trackid"=>"0a368e63-dddf-441f-849c-ca23f9cb2d49", - # "musicbrainz_albumartistid"=>"89ad4ac3-39f7-470e-963a-56509c546377"}> - - # check if the file is correctly tagged by MusicBrainz - def mb_tagged? - !@musicbrainz_infos.empty? - end - - private - - def sanitize(input) - s = input.is_a?(Array) ? input.first : input - s.delete("\000") - end - - def default_fill_musicbrainz_fields(tags = @info.tag) - MUSICBRAINZ_FIELDS.each_key do |field| - val = tags["musicbrainz_#{field}"] - @musicbrainz_infos[field] = val if val - end - end - - def default_tag_fill(tags = @info.tag) - %w[artist album title].each do |v| - instance_variable_set("@#{v}".to_sym, sanitize(tags[v] || '')) - end - end - - def fill_ape_tag(filename) - @info = ApeTag.new(filename) - tags = @info.fields.each_with_object({}) do |(k, v), hash| - hash[k.downcase] = v ? v.first : nil - end - default_fill_musicbrainz_fields(tags) - default_tag_fill(tags) - - @date = tags['year'] - @tracknum = tags['track'].to_i - rescue ApeTagError - end - - def faad_info(path) - require 'open3' - - output = '' - status = nil - - begin - Open3.popen3('faad', '-i', path) do |_stdin, _stdout, stderr, wait_thr| - output = stderr.read.chomp - status = wait_thr.value - end - rescue StandardError - end - - status&.exitstatus&.zero? ? output : '' - end - - def shell_escape(s) - "'#{s.gsub(/'/) { "'\\''" }}'" - end - - def tag_with_shell_command(*command_arr) - expand_command = proc do |hash| - command_arr.collect do |token| - token.is_a?(Symbol) ? hash[token] : token - end.flatten - end - - hash = { src: @path } - if command_arr.include?(:dst) - Tempfile.open(['ruby-audioinfo', ".#{@extension}"]) do |tf| - cmd = expand_command.call(hash.merge(dst: tf.path)) - tf.close - if system(*cmd) - FileUtils.mv(tf.path, @path) - else - raise(AudioInfoError, "error while running #{command_arr[0]}") - end - end - else - cmd = expand_command.call(hash) - system(*cmd) || raise(AudioInfoError, "error while running #{command_arr[0]}") - end + klass.new(filename, extension) end end diff --git a/lib/audioinfo/album.rb b/lib/audioinfo/album.rb index e1cbbc6..cbd91e2 100644 --- a/lib/audioinfo/album.rb +++ b/lib/audioinfo/album.rb @@ -6,6 +6,8 @@ module AudioInfo class Album IMAGE_EXTENSIONS = %w[jpg jpeg gif png].freeze + SUPPORTED_EXTENSIONS = %w[mp3 ogg opus spx mpc wma mp4 aac m4a flac wav].freeze + # a regexp to match the "multicd" suffix of a "multicd" string # example: "toto (disc 1)" will match ' (disc 1)' MULTICD_REGEXP = /\s*(\(|\[)?\s*(disc|cd):?-?\s*(\d+).*(\)|\])?\s*$/i.freeze @@ -46,7 +48,7 @@ def initialize(path, fast_lookup = false) @path = path @multicd = false @basename = @path - exts = AudioInfo::SUPPORTED_EXTENSIONS.collect do |ext| + exts = SUPPORTED_EXTENSIONS.collect do |ext| ext.gsub(/[a-z]/) { |c| "[#{c.downcase}#{c.upcase}]" } end.join(',') diff --git a/lib/audioinfo/ape.rb b/lib/audioinfo/ape.rb new file mode 100644 index 0000000..0e903ef --- /dev/null +++ b/lib/audioinfo/ape.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'apetag' + +module AudioInfo + class Ape < AudioFile + def parse(filename) + @info = ApeTag.new(filename) + + tags = @info.fields.each_with_object({}) do |(k, v), hash| + hash[k.downcase] = v ? v.first : nil + end + + default_fill_musicbrainz_fields(tags) + default_tag_fill(tags) + + @date = tags['year'] + @tracknum = tags['track'].to_i + rescue ApeTagError => e + raise AudioInfoError, e.to_s, e.backtrace + end + + def close + return unless @needs_commit + + ape = ApeTag.new(@path) + ape.update do |fields| + fields['Artist'] = @artist + fields['Album'] = @album + fields['Title'] = @title + fields['Track'] = @tracknum.to_s + end + + @needs_commit + end + end +end diff --git a/lib/audioinfo/audio_file.rb b/lib/audioinfo/audio_file.rb new file mode 100644 index 0000000..f50bdc7 --- /dev/null +++ b/lib/audioinfo/audio_file.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module AudioInfo + class AudioFile + MUSICBRAINZ_FIELDS = { + 'trmid' => 'TRM Id', + 'artistid' => 'Artist Id', + 'albumid' => 'Album Id', + 'albumtype' => 'Album Type', + 'albumstatus' => 'Album Status', + 'albumartistid' => 'Album Artist Id', + 'sortname' => 'Sort Name', + 'trackid' => 'Track Id' + }.freeze + + attr_reader :path, :extension, :musicbrainz_infos, :tracknum, :bitrate, :vbr, :artist, :album, :title, :length, + :date + + def initialize(filename, extension) + @path = filename + ext = File.extname(@path) + @extension = extension || (ext && ext[1..].downcase) + @musicbrainz_infos = {} + @tracknum = nil + @artist = nil + @album = nil + @title = nil + # @date = nil + + parse(filename) + + @tracknum = nil if @tracknum&.zero? + @needs_commit = false + @musicbrainz_infos.delete_if { |_k, v| v.nil? } + + @hash = { + 'artist' => @artist, + 'album' => @album, + 'title' => @title, + 'tracknum' => @tracknum, + 'date' => @date, + 'length' => @length, + 'bitrate' => @bitrate + } + end + + def mb_tagged? + !@musicbrainz_infos.empty? + end + + # convert tags to hash + def to_h + @hash + end + + def title=(new_title) + return if @title == new_title + + @needs_commit = true + @title = new_title + end + + def artist=(new_artist) + return if @artist == new_artist + + @needs_commit = true + @artist = new_artist + end + + def album=(new_album) + return if @album == new_album + + @needs_commit = true + @album = new_album + end + + def tracknum=(tracknum_s) + new_tracknum = tracknum_s.to_i + return if @tracknum == new_tracknum + + @needs_commit = true + @tracknum = new_tracknum + end + + def picture=(filepath) + return if @picture == filepath + + @needs_commit = true + @picture = filepath + end + + # hash-like access to tag + def [](key) + @hash[key] + end + + def close + return unless @needs_commit + + have_ffmpeg = system('which ffmpeg > /dev/null') + + raise(AudioInfoError, "ffmpeg is required to write this file type and wasn't found") unless have_ffmpeg + + tags = { 'artist' => @artist, + 'album' => @album, + 'title' => @title }.inject([]) do |t, (key, value)| + t + ['-metadata', "#{key}=#{value}"] + end + + tag_with_shell_command('ffmpeg', '-y', '-i', :src, '-loglevel', 'quiet', tags, :dst) + + @needs_commit + end + + protected + + def default_tag_fill(tags = @info.tag) + %w[artist album title].each do |v| + instance_variable_set("@#{v}".to_sym, sanitize(tags[v] || '')) + end + end + + def default_fill_musicbrainz_fields(tags = @info.tag) + MUSICBRAINZ_FIELDS.each_key do |field| + val = tags["musicbrainz_#{field}"] + @musicbrainz_infos[field] = val if val + end + end + + def sanitize(input) + s = input.is_a?(Array) ? input.first : input + s.delete("\000") + end + + def tag_with_shell_command(*command_arr) + expand_command = proc do |hash| + command_arr.collect do |token| + token.is_a?(Symbol) ? hash[token] : token + end.flatten + end + + hash = { src: @path } + if command_arr.include?(:dst) + Tempfile.open(['ruby-audioinfo', ".#{@extension}"]) do |tf| + cmd = expand_command.call(hash.merge(dst: tf.path)) + tf.close + + raise(AudioInfoError, "error while running #{command_arr[0]}") unless system(*cmd) + + FileUtils.mv(tf.path, @path) + end + else + cmd = expand_command.call(hash) + system(*cmd) || raise(AudioInfoError, "error while running #{command_arr[0]}") + end + end + end +end diff --git a/lib/audioinfo/case_insensitive_hash.rb b/lib/audioinfo/case_insensitive_hash.rb index c5717ff..4bd495f 100644 --- a/lib/audioinfo/case_insensitive_hash.rb +++ b/lib/audioinfo/case_insensitive_hash.rb @@ -1,18 +1,20 @@ # frozen_string_literal: true -class CaseInsensitiveHash < Hash - def initialize(hash = {}) - super - hash.each do |key, value| - self[key.downcase] = value +module AudioInfo + class CaseInsensitiveHash < Hash + def initialize(hash = {}) + super + hash.each do |key, value| + self[key.downcase] = value + end end - end - def [](key) - super(key.downcase) - end + def [](key) + super(key.downcase) + end - def []=(key, value) - super(key.downcase, value) + def []=(key, value) + super(key.downcase, value) + end end end diff --git a/lib/audioinfo/flac.rb b/lib/audioinfo/flac.rb new file mode 100644 index 0000000..dcb8c15 --- /dev/null +++ b/lib/audioinfo/flac.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'flacinfo' + +require_relative 'case_insensitive_hash' + +module AudioInfo + class Flac < AudioFile + def parse(filename) + @info = FlacInfo.new(filename) + # Unfortunately, FlacInfo doesn't allow us to fiddle inside + # their class, so we have to brute force it. Any other + # solution (e.g. creating another abstraction or getting + # methods) lands up being more messy and brittle. + @info.instance_variable_set('@tags', CaseInsensitiveHash.new(@info.tags)) + + @artist = get_tag('artist') + @album = get_tag('album') + @title = get_tag('title') + @tracknum = @info.tags['tracknumber'].to_i + @date = get_tag('date') + @bitrate = 0 + @length = @info.streaminfo['total_samples'] / @info.streaminfo['samplerate'].to_f + @bitrate = File.size(filename).to_f * 8 / @length / 1024 if @length.positive? + @info.tags.each do |tagname, _tagvalue| + next unless tagname =~ /^musicbrainz_(.+)$/ + + @musicbrainz_infos[Regexp.last_match(1)] = get_tag(tagname) + end + @musicbrainz_infos['trmid'] = @info.tags['musicip_puid'] + # default_fill_musicbrainz_fields + end + + def close + return unless @needs_commit + + have_metaflac = system('which metaflac > /dev/null') + + if have_metaflac + tags = { 'ARTIST' => @artist, + 'ALBUM' => @album, + 'TITLE' => @title, + 'TRACKNUMBER' => @tracknum }.inject([]) do |t, (key, value)| + t + ['--set-tag', "#{key}=#{value}"] + end + tag_with_shell_command('metaflac', '--remove-all', :src) + tag_with_shell_command('metaflac', tags, :src) + else + super + end + + @needs_commit + end + + private + + def get_tag(name) + @info.tags[name]&.dup&.force_encoding('utf-8') + end + end +end diff --git a/lib/audioinfo/mp3.rb b/lib/audioinfo/mp3.rb new file mode 100644 index 0000000..360d954 --- /dev/null +++ b/lib/audioinfo/mp3.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'mp3info' + +module AudioInfo + class Mp3 < AudioFile + def parse(filename) + @info = Mp3Info.new(filename) + default_tag_fill + + # Mp3Info parses additional tags into TXXX + if (arr = @info.tag2['TXXX']).is_a?(Array) + fields = MUSICBRAINZ_FIELDS.invert + arr.each do |val| + next unless val =~ /^MusicBrainz (.+)\000(.*)$/ + + short_name = fields[Regexp.last_match(1)] + next unless short_name + + @musicbrainz_infos[short_name] = + Regexp.last_match(2).gsub(String.new("\xEF\xBB\xBF").force_encoding('UTF-8'), '') + end + end + + # MusicBrainz Track ID is over here: + @musicbrainz_infos['trackid'] = @info.tag2['UFID']&.split("\x00")&.last + + @bitrate = @info.bitrate + i = @info.tag.tracknum + @tracknum = (i.is_a?(Array) ? i.last : i).to_i + @length = @info.length.to_i + @date = @info.tag['date'] + @vbr = @info.vbr + @info.close + rescue Mp3InfoError => e + raise AudioInfoError, e.to_s, e.backtrace + end + + def close + return unless @needs_commit + + Mp3Info.open(@path) do |info| + info.tag.artist = @artist + info.tag.title = @title + info.tag.album = @album + info.tag.tracknum = @tracknum + if @picture + info.tag2.remove_pictures + info.tag2.add_picture(File.binread(@picture)) + end + end + + @needs_commit + end + end +end diff --git a/lib/audioinfo/mp4.rb b/lib/audioinfo/mp4.rb new file mode 100644 index 0000000..fe55cbd --- /dev/null +++ b/lib/audioinfo/mp4.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'mp4info' +require 'open3' + +module AudioInfo + class Mp4 < AudioFile + def parse(filename) + @extension = 'mp4' + @info = MP4Info.open(filename) + @artist = @info.ART + @album = @info.ALB + @title = @info.NAM + @tracknum = (t = @info.TRKN) ? t.first : 0 + @date = @info.DAY + @bitrate = @info.BITRATE + @length = @info.SECS + mapping = MUSICBRAINZ_FIELDS.invert + + faad_info(filename).scan(/^MusicBrainz (.+): (.+)$/) do |match| + name, value = match + key = mapping[name] + next unless key + + @musicbrainz_infos[key] = value.strip.gsub("\u0000", '') + end + end + + def faad_info(path) + output = '' + status = nil + + return unless system('which faad > /dev/null') + + Open3.popen3('faad', '-i', path) do |_stdin, _stdout, stderr, wait_thr| + output = stderr.read.chomp + status = wait_thr.value + end + + status.exitstatus.zero? ? output : '' + end + end +end diff --git a/lib/audioinfo/mpc.rb b/lib/audioinfo/mpc.rb new file mode 100644 index 0000000..1e0c353 --- /dev/null +++ b/lib/audioinfo/mpc.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'apetag' +require 'mpcinfo' + +module AudioInfo + class Mpc < Ape + def parse(filename) + super(filename) + + mpc_info = MpcInfo.new(filename) + @bitrate = mpc_info.infos['bitrate'] / 1000 + @length = mpc_info.infos['length'] + end + end +end diff --git a/lib/audioinfo/ogg.rb b/lib/audioinfo/ogg.rb new file mode 100644 index 0000000..79424b3 --- /dev/null +++ b/lib/audioinfo/ogg.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'ogginfo' + +module AudioInfo + class Ogg < AudioFile + def parse(filename) + @info = OggInfo.new(filename) + default_fill_musicbrainz_fields + default_tag_fill + @bitrate = @info.bitrate / 1000 + @tracknum = @info.tag.tracknumber.to_i + @length = @info.length.to_i + @date = @info.tag['date'] + @vbr = true + @info.close + rescue OggInfoError => e + raise AudioInfoError, e.to_s, e.backtrace + end + + def close + return unless @needs_commit + + OggInfo.open(@path) do |ogg| + { 'artist' => @artist, + 'album' => @album, + 'title' => @title, + 'tracknumber' => @tracknum }.each do |k, v| + ogg.tag[k] = v.to_s + end + ogg.picture = @picture if @picture + end + + @needs_commit + end + end +end diff --git a/lib/audioinfo/version.rb b/lib/audioinfo/version.rb index 84bd421..d01a5a4 100644 --- a/lib/audioinfo/version.rb +++ b/lib/audioinfo/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true -class AudioInfo +module AudioInfo VERSION = '0.5.5' end diff --git a/lib/audioinfo/wav.rb b/lib/audioinfo/wav.rb new file mode 100644 index 0000000..b31880a --- /dev/null +++ b/lib/audioinfo/wav.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'wavefile' + +module AudioInfo + class Wav < AudioFile + def parse(filename) + @info = WaveFile::Reader.info(filename) + @length = @info.duration.hours * 3600 + @info.duration.minutes * 60 + @info.duration.seconds + + @info.duration.milliseconds * 0.001 + @bitrate = File.size(filename) * 8 / @length / 1024 + end + end +end diff --git a/lib/audioinfo/wma.rb b/lib/audioinfo/wma.rb new file mode 100644 index 0000000..ab2ddf7 --- /dev/null +++ b/lib/audioinfo/wma.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'wmainfo' + +module AudioInfo + class Wma < AudioFile + def parse(filename) + @info = WmaInfo.new(filename, encoding: 'utf-8') + tags = @info.tags.map { |k, v| [k.strip, v.strip] }.to_h + + @artist = tags['Author'] + @album = tags['AlbumTitle'] + @title = tags['Title'] + @tracknum = tags['TrackNumber'].to_i + @date = tags['Year'] + @bitrate = @info.info['bitrate'] + @length = @info.info['playtime_seconds'] + + info = @info.info.map do |k, v| + [ + k.respond_to?(:strip) ? k.strip : k, + v.respond_to?(:strip) ? v.strip : v + ] + end.to_h + + MUSICBRAINZ_FIELDS.each do |key, original_key| + @musicbrainz_infos[key] = + info["MusicBrainz/#{original_key.tr(' ', '')}"] || + info["MusicBrainz/#{original_key}"] + end + end + end +end diff --git a/test/case_insensitive_hash_test.rb b/test/case_insensitive_hash_test.rb index 8964ec4..0c170f5 100644 --- a/test/case_insensitive_hash_test.rb +++ b/test/case_insensitive_hash_test.rb @@ -4,7 +4,7 @@ class CaseInsensitiveHashTest < MiniTest::Test def setup - @h = CaseInsensitiveHash.new + @h = AudioInfo::CaseInsensitiveHash.new end def test_string_access @@ -23,7 +23,7 @@ def test_case_insensitive_access end def test_copy_constructor - h = CaseInsensitiveHash.new({ 'FOO' => 'bar' }) + h = AudioInfo::CaseInsensitiveHash.new({ 'FOO' => 'bar' }) assert_equal 'bar', h['foo'] end end diff --git a/test/flac_test.rb b/test/flac_test.rb index 30429a0..32b242b 100644 --- a/test/flac_test.rb +++ b/test/flac_test.rb @@ -12,16 +12,12 @@ class FlacTest < MiniTest::Test FLAC_FILE = "#{Dir.tmpdir}/ruby-audioinfo-test.flac" def setup - FileUtils.cp(File.join(SUPPORT_DIR, '440Hz-5sec.flac'), FLAC_FILE) + FileUtils.cp(File.join(SUPPORT_DIR, 'cantina_band.flac'), FLAC_FILE) @i = AudioInfo.new(FLAC_FILE) end - def test_flac_whitelist - assert_kind_of FlacInfo, @i.info - end - - def test_flac_tags_wrapper - assert_kind_of CaseInsensitiveHash, @i.info.tags + def test_default_fields + assert_equal(DEFAULT_INFO.merge('length' => 3.0, 'bitrate' => 216.0859375), @i.to_h) end def test_flac_writing diff --git a/test/m4a_test.rb b/test/m4a_test.rb index 08a9136..59fbf08 100644 --- a/test/m4a_test.rb +++ b/test/m4a_test.rb @@ -13,15 +13,14 @@ def setup @i = AudioInfo.new(M4A_FILE) end - def test_whitelist - assert_kind_of MP4Info, @i.info - end - - def test_length - assert_in_delta(@i.length, 3) + def test_default_fields + assert_equal( + DEFAULT_INFO.slice('artist', 'length', 'tracknum').merge('bitrate' => 113), + @i.to_h.compact + ) end def test_musicbrainz - assert_equal('57c051a1-41db-4764-bfab-ecac5cb3a144', @i.musicbrainz_infos['artistid']) + assert_equal(MUSICBRAINZ_INFO, @i.musicbrainz_infos) end end diff --git a/test/mp3_test.rb b/test/mp3_test.rb new file mode 100644 index 0000000..06670b4 --- /dev/null +++ b/test/mp3_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +require_relative 'test_helper' + +class MP3Test < MiniTest::Test + MP3_FILE = "#{Dir.tmpdir}/ruby-audioinfo-test.mp3" + + # mp3 loads both artists while other formats do not + ARTIST = "#{MUSICBRAINZ_INFO['artistid']}/4ebf48b9-9b17-43e2-b4aa-7f29b7e608d1" + + def setup + FileUtils.cp(File.join(SUPPORT_DIR, 'cantina_band.mp3'), MP3_FILE) + @i = AudioInfo.new(MP3_FILE) + end + + def test_default_fields + assert_equal(DEFAULT_INFO.merge('date' => nil), @i.to_h) + end + + def test_musicbrainz + assert_equal( + MUSICBRAINZ_INFO.merge('artistid' => ARTIST, 'albumartistid' => ARTIST), + @i.musicbrainz_infos + ) + end +end diff --git a/test/ogg_test.rb b/test/ogg_test.rb new file mode 100644 index 0000000..5d0b8f5 --- /dev/null +++ b/test/ogg_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +require_relative 'test_helper' + +class OggTest < MiniTest::Test + OGG_FILE = "#{Dir.tmpdir}/ruby-audioinfo-test.ogg" + + def setup + FileUtils.cp(File.join(SUPPORT_DIR, 'cantina_band.ogg'), OGG_FILE) + @i = AudioInfo.new(OGG_FILE) + end + + def test_default_fields + assert_equal DEFAULT_INFO.merge('bitrate' => 31.557333333333332), @i.to_h + end +end diff --git a/test/support/440Hz-5sec.flac b/test/support/440Hz-5sec.flac deleted file mode 100644 index a23bf90..0000000 Binary files a/test/support/440Hz-5sec.flac and /dev/null differ diff --git a/test/support/cantina_band.flac b/test/support/cantina_band.flac new file mode 100644 index 0000000..8c41555 Binary files /dev/null and b/test/support/cantina_band.flac differ diff --git a/test/support/cantina_band.mp3 b/test/support/cantina_band.mp3 new file mode 100644 index 0000000..124c299 Binary files /dev/null and b/test/support/cantina_band.mp3 differ diff --git a/test/support/cantina_band.mpc b/test/support/cantina_band.mpc new file mode 100644 index 0000000..165e383 Binary files /dev/null and b/test/support/cantina_band.mpc differ diff --git a/test/support/cantina_band.ogg b/test/support/cantina_band.ogg new file mode 100644 index 0000000..d1c8d30 Binary files /dev/null and b/test/support/cantina_band.ogg differ diff --git a/test/support/cantina_band.wav b/test/support/cantina_band.wav new file mode 100644 index 0000000..af1d918 Binary files /dev/null and b/test/support/cantina_band.wav differ diff --git a/test/support/cantina_band.wma b/test/support/cantina_band.wma new file mode 100644 index 0000000..7126766 Binary files /dev/null and b/test/support/cantina_band.wma differ diff --git a/test/support/piano2.wav b/test/support/piano2.wav deleted file mode 100644 index 12348ef..0000000 Binary files a/test/support/piano2.wav and /dev/null differ diff --git a/test/test_helper.rb b/test/test_helper.rb index 0f6b9cf..ac60751 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -20,3 +20,21 @@ require 'minitest/rg' SUPPORT_DIR = File.expand_path('support', File.dirname(__FILE__)) + +DEFAULT_INFO = { + 'artist' => 'Martin Spitznagel & Bryan Wright', + 'album' => 'Star Wars: Cantina Band in Ragtime', + 'title' => 'Star Wars: Cantina Band in Ragtime', + 'tracknum' => 1, + 'date' => '2011-03-29', + 'length' => 3, + 'bitrate' => 25 +}.freeze + +MUSICBRAINZ_INFO = { + 'albumtype' => 'single', + 'albumid' => 'c357ac93-8896-4486-9630-05ae04c43345', + 'artistid' => '57c051a1-41db-4764-bfab-ecac5cb3a144', + 'albumartistid' => '57c051a1-41db-4764-bfab-ecac5cb3a144', + 'trackid' => '608be633-8a2b-4b90-b745-e38cae815c20' +}.freeze diff --git a/test/wav_test.rb b/test/wav_test.rb index a566889..56421fb 100644 --- a/test/wav_test.rb +++ b/test/wav_test.rb @@ -9,15 +9,15 @@ class TestWav < MiniTest::Test WAV_FILE = "#{Dir.tmpdir}/ruby-audioinfo-test.wav" def setup - FileUtils.cp(File.join(SUPPORT_DIR, 'piano2.wav'), WAV_FILE) + FileUtils.cp(File.join(SUPPORT_DIR, 'cantina_band.wav'), WAV_FILE) @i = AudioInfo.new(WAV_FILE) end - def test_wav_whitelist - assert_kind_of WaveFile::Info, @i.info + def test_default_fields + assert_equal({ 'length' => 3.0, 'bitrate' => 352.8645833333333 }, @i.to_h.compact) end - def test_wav_length - assert_in_delta(@i.length, 6.306) + def test_musicbrainz + assert_equal({}, @i.musicbrainz_infos) end end diff --git a/test/wma_test.rb b/test/wma_test.rb new file mode 100644 index 0000000..b9e85d0 --- /dev/null +++ b/test/wma_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require 'fileutils' +require 'tmpdir' + +require_relative 'test_helper' + +class WmaTest < MiniTest::Test + WMA_FILE = "#{Dir.tmpdir}/ruby-audioinfo-test.wma" + + def setup + FileUtils.cp(File.join(SUPPORT_DIR, 'cantina_band.wma'), WMA_FILE) + @i = AudioInfo.new(WMA_FILE) + end + + def test_default_fields + assert_equal(DEFAULT_INFO.merge('bitrate' => 24, 'length' => 4), @i.to_h) + end + + def test_musicbrainz + assert_equal MUSICBRAINZ_INFO.sort, @i.musicbrainz_infos.sort + end +end