#!/usr/bin/env ruby # # omptagger # http://github.com/omp/omptagger # Modify and display metadata of audio files. # # Copyright 2007-2014 David Vazgenovich Shakaryan # Distributed under the terms of the GNU General Public License v3. # See http://www.gnu.org/licenses/gpl.txt for the full license text. # # Dependencies: # ruby http://www.ruby-lang.org/ # TagLib http://taglib.github.io/ # taglib-ruby http://robinst.github.io/taglib-ruby/ require 'getoptlong' require 'taglib' module Output @@colour = true def self.colour=(boolean) @@colour = boolean end def self.colourise(string, colour) return string unless @@colour case colour when :green code = '32' when :yellow code = '33' when :cyan code = '36' else return string end "\e[#{code}m#{string}\e[0m" end def self.file(file) puts colourise("#{file}:", :yellow) end def self.action(action) puts " #{colourise("#{action}:", :green)}" end def self.field(field, value, padding = 0) puts " #{colourise(field, :cyan)} #{' ' * padding} #{value}" end def self.info(info) puts " #{info}" end def self.help puts <<-end Usage: omptagger [actions/options] [files] Actions: --view -v View all tags --view-tag -t View a tag --add-tag -a Add a tag --set-tag -s Set a tag --remove -r Remove all tags --remove-tag -d Remove a tag --generate -g Generate tags based on filename --rename -m Rename file based on tags --scheme -n Change file naming scheme Options: --no-colour -c Disable colourisation of output --pretend -p Disable finalisation of changes See the man page for: - A complete list of options. - Detailed explanations and examples for each option. - General usage information, as well as various tips. end end end class Datum def initialize(filename) raise 'File does not exist.' unless File.exist?(filename) Output.file(filename) @filename = File.expand_path(filename) case File.extname(filename) when '.flac' type = FLAC when '.ogg', '.oga' type = Vorbis when '.mp3' type = MP3 else raise 'File extension not recognised.' end @metadata = type.new(@filename) end def execute(action) Output.action(action.to_s) begin @metadata.send(action.action, *action.arguments) rescue MetadataError => e Output.info(e.message) end end def save! @metadata.save! if @metadata.write unless @filename == @metadata.filename File.rename(@filename, @metadata.filename) end end end class Action attr_reader :action, :arguments def initialize(action, *arguments) @action = action @arguments = arguments end def to_s case @action when :view str = 'Viewing all fields' when :view_tag str = 'Viewing %s field' when :add_tag str = 'Adding %s field' when :set_tag str = 'Setting %s field' when :remove str = 'Removing all fields' when :remove_tag str = 'Removing %s field' when :generate str = 'Generating fields' when :rename str = 'Renaming file' end str % @arguments end end class MetadataError < RuntimeError def initialize(error) @error = error end def to_s case @error when :empty str = 'No fields set.' when :generate str = 'Filename does not match scheme.' when :invalid str = 'Invalid field name.' when :rename str = 'Insufficient tags.' when :unset str = 'Field not set.' end return str end end class Array def longest_element_length self.inject(0) do |longest, key| key.length > longest ? key.length : longest end end end class Hash def longest_key_length self.keys.inject(0) do |longest, key| key.length > longest ? key.length : longest end end end class Metadata attr_reader :filename, :write def initialize(file) @filename = File.expand_path(file) @file = open(file) @metadata = read @write = false end def view raise MetadataError, :empty if @metadata.empty? @metadata.sort.each do |field, value| value.each do |value| Output.field(field, value, @metadata.longest_key_length - field.length) end end end def view_tag(field) field.upcase! raise MetadataError, :unset unless @metadata.has_key?(field) @metadata[field].each do |value| Output.field(field, value) end end def add_tag(field, value, padding = 0) field.upcase! raise MetadataError, :invalid unless valid_field?(field) if @metadata.has_key?(field) @metadata[field] << value else @metadata[field] = [value] end Output.field(field, value, padding) @write = true end def set_tag(field, value, padding = 0) field.upcase! raise MetadataError, :invalid unless valid_field?(field) @metadata[field] = [value] Output.field(field, value, padding) @write = true end def remove raise MetadataError, :empty if @metadata.empty? @metadata.clear Output.info('Removed.') @write = true end def remove_tag(field) field.upcase! raise MetadataError, :unset unless @metadata.has_key?(field) @metadata.delete(field) Output.info('Removed.') @write = true end def generate(scheme) regexp = Regexp.escape(scheme) regexp = regexp.gsub(/%([#{keys.keys.join}]|\\\*)/, '([^/]*?)') regexp = /#{regexp}\Z/ fields = scheme.scan(regexp).flatten values = @filename.chomp(File.extname(@filename)).scan(regexp).flatten raise MetadataError, :generate unless fields.length == values.length fields.collect! do |field| keys[field[1,1]] end longest = fields.compact.longest_element_length fields.zip(values).each do |field, value| unless field.nil? add_tag(field, value, longest - field.length) end end end def rename(scheme) scheme.scan(/%([#{keys.keys.join}])/).flatten.uniq.each do |field| raise MetadataError, :rename unless @metadata[keys[field]] scheme = scheme.gsub("%#{field}", @metadata[keys[field]].first) end scheme << File.extname(@filename) @filename = File.join(File.dirname(@filename), scheme) Output.info(@filename) end end class VorbisComment < Metadata def save! read.each_key do |field| metadata.remove_field(field) end @metadata.each do |field, value| value.each do |value| metadata.add_field(field, value, false) end end @file.save end private def keys Hash['a' => 'ARTIST', 'b' => 'ALBUM', 'd' => 'DATE', 'n' => 'TRACKNUMBER', 't' => 'TITLE'] end def read metadata.field_list_map end def valid_field?(field) valid = (32..125).collect do |character| character.chr end valid.delete('=') field.scan(/./).each do |character| return false unless valid.include?(character) end return true end end class FLAC < VorbisComment private def open(file) TagLib::FLAC::File.new(file) end def metadata @file.xiph_comment end end class Vorbis < VorbisComment private def open(file) TagLib::Ogg::Vorbis::File.new(file) end def metadata @file.tag end end class MP3 < Metadata def save! read.each_key do |field| metadata.remove_frames(field) end @metadata.each do |field, value| value.each do |value| frame = TagLib::ID3v2::TextIdentificationFrame.new(field, TagLib::String::UTF8) frame.text = value metadata.add_frame(frame) if metadata.frame_list(field).empty? end end @file.save end private def keys Hash['a' => 'TPE1', 'b' => 'TALB', 'd' => 'TDRC', 'n' => 'TRCK', 't' => 'TIT2'] end def open(file) TagLib::MPEG::File.new(file) end def metadata @file.id3v2_tag end def read metadata.frame_list.inject({}) { |m, e| m[e.frame_id] = [e.to_string]; m } end def valid_field?(field) true end end actions = [] options = {scheme: '%n - %a - %t'} GetoptLong.new( ['--view', '-v', GetoptLong::NO_ARGUMENT], ['--view-tag', '-t', GetoptLong::REQUIRED_ARGUMENT], ['--add-tag', '-a', GetoptLong::REQUIRED_ARGUMENT], ['--set-tag', '-s', GetoptLong::REQUIRED_ARGUMENT], ['--remove', '-r', GetoptLong::NO_ARGUMENT], ['--remove-tag', '-d', GetoptLong::REQUIRED_ARGUMENT], ['--generate', '-g', GetoptLong::NO_ARGUMENT], ['--rename', '-m', GetoptLong::NO_ARGUMENT], ['--scheme', '-n', GetoptLong::REQUIRED_ARGUMENT], ['--no-colour', '-c', GetoptLong::NO_ARGUMENT], ['--pretend', '-p', GetoptLong::NO_ARGUMENT], ['--help', '-h', GetoptLong::NO_ARGUMENT] ).each do |opt, arg| opt = opt.sub(/\A--/, '').gsub('-', '_').intern case opt when :help Output.help exit when :no_colour, :pretend, :scheme options[opt] = arg when :view_tag, :remove_tag actions << Action.new(opt, arg.dup) when :add_tag, :set_tag raise "Argument must contain '='." unless arg.include?('=') actions << Action.new(opt, *arg.split('=', 2)) when :generate, :rename actions << Action.new(opt, options[:scheme]) else actions << Action.new(opt) end end if actions.empty? $stderr.puts 'No actions specified. See the --help option.' exit elsif ARGV.empty? $stderr.puts 'No files specified.' exit end Output.colour = false if options.has_key?(:no_colour) ARGV.each do |filename| begin datum = Datum.new(filename) actions.each do |action| datum.execute(action) end datum.save! unless options[:pretend] rescue RuntimeError => e $stderr.puts "#{$0}: #{filename}: #{e.message}" end end