#!/usr/bin/env ruby
require 'getoptlong'
require 'id3lib'
def colred(str) return "\e[31m#{str}\e[0m" end
def colgrn(str) return "\e[32m#{str}\e[0m" end
def colyel(str) return "\e[33m#{str}\e[0m" end
def colcyn(str) return "\e[36m#{str}\e[0m" end
def esc(str)
return str.gsub("'", "'\\\\''")
end
def output(tag, val)
tag = tag.downcase.capitalize unless tag == 'ISRC'
tag = 'TrackNumber' if tag == 'Tracknumber'
puts ' ' + colcyn(tag) + ' ' * (14 - tag.length) + val
end
def schemesplit(scheme, file, keystr)
regexp = /\A#{Regexp.escape(scheme).gsub(/%([#{keystr}]|\\\*)/, '(.*?)')}\Z/
keys = scheme.scan(regexp).flatten
vals = file.scan(regexp).flatten
raise 'Filename does not match naming scheme.' if keys.length != vals.length
result = Hash.new
keys.zip(vals).each do |key, val|
result[key[1,1]] = val unless key == '%*'
end
return result
end
def help
options = [['--view -v', 'View all tags'],
['--view-tag -t', 'View 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', 'Generate filename based on tags'],
['--scheme -n', 'Specify a file naming scheme'],
['--pretend -p', 'Do not make any actual changes'],
['--no-colour -c', 'Disable use of colour in output'],
['--list -l', 'Display list of available tags'],
['--help -h', 'Display help information']]
notes = ['The default file naming scheme is Artist - Title.',
'Schemes must be specified prior to actions that use them.',
'Underscores in filenames are converted to spaces in tags.']
puts colyel('Usage:') + ' omptagger ' + colgrn('[options] [files]')
puts
puts colyel('Options:')
options.each do |option, description|
puts ' ' + colgrn(option) + ' ' + description
end
puts
puts colyel('Notes:')
notes.each do |note|
puts ' ' + colgrn('*') + ' ' + note
end
exit
end
def list
puts '┌───────────────────────────────────┐',
'│ Keys Vorbis Comments ID3 Tags │',
'├───────────────────────────────────┤',
'│ %a Artist Artist │',
'│ %b Album Album │',
'│ %d Date Year │',
'│ %n TrackNumber Track │',
'│ %t Title Title │',
'│ %* Wildcard Wildcard │',
'├───────────────────────────────────┤',
'│ Contact │',
'│ Copyright │',
'│ Description │',
'│ Genre │',
'│ ISRC │',
'│ License │',
'│ Location │',
'│ Organization │',
'│ Performer │',
'│ Version │',
'└───────────────────────────────────┘'
exit
end
class VorbisComments
TAGS = ['ALBUM',
'ARTIST',
'CONTACT',
'COPYRIGHT',
'DATE',
'DESCRIPTION',
'GENRE',
'ISRC',
'LICENSE',
'LOCATION',
'ORGANIZATION',
'PERFORMER',
'TITLE',
'TRACKNUMBER',
'VERSION']
KEYS = Hash['a' => 'ARTIST',
'b' => 'ALBUM',
'd' => 'DATE',
'n' => 'TRACKNUMBER',
't' => 'TITLE']
def initialize(file)
@file = @origfile = file
@tags = Hash.new
@write = false
TAGS.each do |tag|
val = read_tag(tag)
next if val.empty?
@tags[tag] = val.gsub(/^#{tag}=/i, '').split("\n")
end
end
def view
raise 'No tags are set.' if @tags.empty?
@tags.each do |tag, val|
val.each do |val|
output(tag, val)
end
end
end
def view_tag(arg)
tag = arg.upcase
raise tag + ' is not a valid tag.' unless TAGS.include?(tag)
val = @tags[tag]
raise tag + ' tag is not set.' if val.nil?
val.each do |val|
output(tag, val)
end
end
def set_tag(arg)
arg = arg.split('=', 2)
tag = arg.shift.upcase
raise tag + ' is not a valid tag.' unless TAGS.include?(tag)
@tags[tag] = arg
view_tag(tag)
@write = true
end
def remove
raise 'No tags are set.' if @tags.empty?
@tags.clear
@write = true
end
def remove_tag(arg)
tag = arg.upcase
raise tag + ' is not a valid tag.' unless TAGS.include?(tag)
raise tag + ' tag is not set.' unless @tags.include?(tag)
@tags.delete(tag)
@write = true
end
def generate(scheme)
val = File.basename(@file, File.extname(@file)).gsub('_', ' ')
scheme = schemesplit(scheme.gsub('_', ' '), val, KEYS.keys.join)
tagval = Array.new
scheme.each do |tag, val|
tagval << [KEYS[tag], val]
end
tagval.each do |arr|
@tags[arr.first] = [arr.last]
view_tag(arr.first)
end
@write = true
end
def rename(scheme)
file = scheme
file.scan(/%([#{KEYS.keys.join}])/).flatten.each do |key|
raise 'Missing ' + KEYS[key] + ' tag.' unless @tags[KEYS[key]]
file = file.gsub('%' + key, @tags[KEYS[key]].first)
end
file = File.dirname(@file) + '/' + file.gsub('/', '_') +
File.extname(@file)
raise 'Generated filename and current filename are identical.' if
File.basename(@file) == File.basename(file)
output('FILENAME', file.sub(/^\.\//, ''))
raise 'File with generated name already exists.' if File.exist?(file)
@file = file
end
def finalise
if @write
clear_tags
@tags.each do |tag, val|
val.each do |val|
write_tag(tag, val)
end
end
end
File.rename(@origfile, @file) unless @file == @origfile
end
end
class FLAC < VorbisComments
def read_tag(tag)
%x(metaflac --show-tag=#{tag} -- '#{esc(@origfile)}').chomp
end
def clear_tags
%x{metaflac --remove-all-tags -- '#{esc(@origfile)}'}
end
def write_tag(tag, val)
%x{metaflac --set-tag=#{tag}='#{esc(val)}' -- '#{esc(@origfile)}'}
end
end
class Vorbis < VorbisComments
def read_tag(tag)
%x(vorbiscomment -l -- '#{esc(@origfile)}' | grep -i '^#{tag}=').chomp
end
def clear_tags
%x{vorbiscomment -w -t '' -- '#{esc(@origfile)}' 2>/dev/null}
end
def write_tag(tag, val)
%x{vorbiscomment -a -t #{tag}='#{esc(val)}' -- '#{esc(@origfile)}'}
end
end
class ID3
TAGS = Hash['ALBUM' => :TALB,
'ARTIST' => :TPE1,
'COMMENT' => :COMM,
'TITLE' => :TIT2,
'TRACK' => :TRCK,
'YEAR' => :TYER]
KEYS = Hash['a' => 'ARTIST',
'b' => 'ALBUM',
'd' => 'YEAR',
'n' => 'TRACK',
't' => 'TITLE']
def initialize(file)
@file = @origfile = file
@tags = ID3Lib::Tag.new(@file)
@write = false
end
def view
raise 'No tags are set.' if @tags.empty?
TAGS.each do |tag, frame|
val = @tags.frame_text(frame)
output(tag, val) unless val.nil?
end
end
def view_tag(arg)
tag = arg.upcase
raise tag + ' is not a valid tag.' unless TAGS.has_key?(tag)
val = @tags.frame_text(TAGS[tag])
raise tag + ' tag is not set.' if val.nil?
output(tag, val)
end
def set_tag(arg)
arg = arg.split('=', 2)
tag = arg.shift.upcase
raise tag + ' is not a valid tag.' unless TAGS.has_key?(tag)
@tags.set_frame_text(TAGS[tag], arg.to_s)
view_tag(tag)
@write = true
end
def generate(scheme)
val = File.basename(@file, File.extname(@file)).gsub('_', ' ')
scheme = schemesplit(scheme.gsub('_', ' '), val, KEYS.keys.join)
tagval = Array.new
scheme.each do |tag, val|
tagval << [KEYS[tag], val]
end
tagval.each do |arr|
@tags.set_frame_text(TAGS[arr.first], arr.last)
view_tag(arr.first)
end
@write = true
end
def remove
raise 'No tags are set.' if @tags.empty?
@tags.clear
@write = true
end
def remove_tag(arg)
tag = arg.upcase
raise tag + ' is not a valid tag.' unless TAGS.has_key?(tag)
raise tag + ' tag is not set.' if @tags.frame_text(TAGS[tag]).nil?
@tags.remove_frame(TAGS[tag])
@write = true
end
def rename(scheme)
file = scheme
file.scan(/%([#{KEYS.keys.join}])/).flatten.each do |key|
raise 'Missing ' + KEYS[key] + ' tag.' if
@tags.frame_text(TAGS[KEYS[key]]).nil?
file = file.gsub('%' + key, @tags.frame_text(TAGS[KEYS[key]]))
end
file = File.dirname(@file) + '/' + file.gsub('/', '_') +
File.extname(@file)
raise 'Generated filename and current filename are identical.' if
File.basename(@file) == File.basename(file)
output('FILENAME', file.sub(/^\.\//, ''))
raise 'File with generated name already exists.' if File.exist?(file)
@file = file
end
def finalise
if @write
if @tags.empty? or (@tags.length == 1 and @tags.frame_text(:TLEN))
@tags.strip!
else
@tags.update!
end
end
File.rename(@origfile, @file) unless @file == @origfile
end
end
actions = Array.new
options = Array.new
scheme = '%a - %t'
GetoptLong.new(
['--view', '-v', GetoptLong::NO_ARGUMENT],
['--view-tag', '-t', 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],
['--pretend', '-p', GetoptLong::NO_ARGUMENT],
['--no-colour', '-c', GetoptLong::NO_ARGUMENT],
['--list', '-l', GetoptLong::NO_ARGUMENT],
['--help', '-h', GetoptLong::NO_ARGUMENT]
).each do |opt, arg|
opt = opt.sub(/^--/, '').gsub('-', '_').intern
case opt
when :no_colour
def colred(str) return str end
def colgrn(str) return str end
def colyel(str) return str end
def colcyn(str) return str end
when :scheme
scheme = arg
when :pretend, :help, :list
options << opt
when :view_tag, :set_tag, :remove_tag
actions << [opt, arg]
when :generate, :rename
actions << [opt, scheme]
else
actions << [opt]
end
end
if options.include?(:help)
help
elsif options.include?(:list)
list
elsif actions.empty? and ARGV.empty?
help
elsif actions.empty?
actions = [[:view]]
elsif ARGV.empty?
puts colred('ERROR:') + ' No files specified.'
end
ARGV.each do |file|
begin
puts colyel(file)
raise 'File does not exist.' unless File.exist?(file)
case File.extname(file).downcase
when '.flac'
track = FLAC.new(file)
when '.ogg'
track = Vorbis.new(file)
when '.mp3'
track = ID3.new(file)
else
raise 'File extension not recognised.'
end
actions.each do |action|
begin
puts ' ' + colgrn('Action: ' + action.join(' '))
case action.first
when :view, :remove
track.send(action.first)
when :view_tag, :set_tag, :remove_tag, :generate, :rename
track.send(action.first, action.last)
end
rescue RuntimeError => message
puts ' ' + colred('ERROR:') + ' ' + message
end
end
track.finalise unless options.include?(:pretend)
rescue RuntimeError => message
puts ' ' + colred('ERROR:') + ' ' + message
end
end