#!/usr/bin/env ruby # tagvi - edit MP3/OGG/FLAC tags with a text editor # # = Usage # # tagvi *.mp3 - interactive mode, saves on leaving editor # tagvi -r *.mp3 > file - output metadata # tagvi -w < file - write back metadata # # Supports MP3, OGG and FLAC (Xiph Comment only). # For MP3, ID3v1 is upgraded to ID3v2 on write. # # = Special fields # # Using "Track: 5/" numbers track as 5, total is number of input records. # Using "Track: /" numbers track increasingly, total is number of records. # Using "Track: ++" numbers track increasingly, total is last set total. # # ID3v2 comments use "Comment-LANGUAGE-DESCRIPTION: TEXT" where # LANGUAGE has exactly three letters. # # You can set arbitrary ID3v2/Xiph Comment text fields by using their ID. # Pass an empty string to delete a field. # Unmentioned fields are ignored and kept intact. # # = Copying # # Written by Christian Neukirchen . # tagvi is in the public domain. # # To the extent possible under law, the creator of this work has waived # all copyright and related or neighboring rights to this work. # http://creativecommons.org/publicdomain/zero/1.0/ require 'tempfile' # required gem: taglib-ruby require 'taglib' # make taglib-ruby API bearable def TagLib.open(file, &block) (case file when /\.mp3\z/i; TagLib::MPEG::File when /\.ogg\z/i; TagLib::Ogg::Vorbis::File when /\.flac\z/i; TagLib::FLAC::File else raise NotImplementedError, "can't parse #{file}" end).open(file, &block) end class TagLib::FLAC::File def dwim_tag(write=false); xiph_comment; end alias_method :dwim_save, :save end class TagLib::Ogg::Vorbis::File def dwim_tag(write=false); tag; end alias_method :dwim_save, :save end class TagLib::MPEG::File def dwim_tag(write=false) (id3v2_tag.empty? && !write) ? id3v1_tag : id3v2_tag end def dwim_save save(TagLib::MPEG::File::ID3v2) end end class TagLib::ID3v1::Tag FIELDS = %w{album artist comment genre title track date} alias_method :date, :year def [](v) FIELDS.include?(v) ? __send__(v) : nil end def each(&blk) FIELDS.each { |m| blk[m.capitalize, [self[m]]] } end end class TagLib::ID3v2::Tag TAG2HUMAN = { "TPE1" => "Artist", "TPE2" => "Band", "TCOM" => "Composer", "TALB" => "Album", "TPOS" => "Disc", "TCMP" => "Compilation", "TYER" => "Date", # to be overwritten by TDRC on write "TDRC" => "Date", "TORY" => "Released", # to be overwritten by TDOR on write "XDOR" => "Released", # to be overwritten by TDOR on write "TDOR" => "Released", "TCON" => "Genre", "TIT2" => "Title", "TRCK" => "Track", "TLAN" => "Language", "TBPM" => "Bpm", "COMM" => "Comment", } HUMAN2TAG = TAG2HUMAN.invert def [](v) frame_list(v).first end def []=(k, v) k = HUMAN2TAG.fetch(k, k) case k when /\ACOMM\z|\AComment-(...)-(.*)\z/ frame = TagLib::ID3v2::CommentsFrame.new(TagLib::String::UTF8) frame.language = $1 || "eng" frame.description = $2 || "ID3v1 comment" frame.text = v.join("\n") add_frame frame when /^[TWUX]/ remove_frames k frame = TagLib::ID3v2::TextIdentificationFrame.new(k, TagLib::String::UTF8) frame.text = v.join("\n") add_frame frame else warn "Can't set field #{k}" end end def each(&blk) frame_list.each { |f| case f when TagLib::ID3v2::TextIdentificationFrame blk[TAG2HUMAN.fetch(f.frame_id, f.frame_id), f.field_list] when TagLib::ID3v2::CommentsFrame blk["Comment-#{f.language}-#{f.description}", f.text.split("\n")] else blk[f.frame_id, nil] end } end end class TagLib::Ogg::XiphComment def [](v) field_list_map[v] end def []=(k, v) k = k.upcase remove_field(k) v.each { |l| if k == "TRACK" && l =~ %r{\A(\d+)(/(\d+))?\z} self["TRACKNUMBER"] = [$1] self["TRACKTOTAL"] = [$3] if $2 else add_field(k, l, false) end } end def each(&blk) field_list_map.map { |k, v| case k when "TRACKNUMBER" v = "#{v.first.to_i}" if total = self["TRACKTOTAL"] v << "/#{total.first.to_i}" end blk["Track", [v]] next when "TRACKTOTAL" # ignore next end blk[k.capitalize, v] } end end ORDER = TagLib::ID3v2::Tag::HUMAN2TAG.keys def do_read(files, output=STDOUT) files.each { |filename| begin fields = {} TagLib.open(filename) { |file| tag = file.dwim_tag output.puts "File: #{filename}" unless tag.empty? tag.each { |k, v| if v fields[k] = v else output.puts "# ignored #{k}" end } end } fields.keys.sort_by { |k| [ORDER.index(k) || 999, k] }.each { |k| fields[k].each { |l| output.puts "%s: %s" % [k, l] } } output.puts rescue NotImplementedError warn $! end } end def do_write(data) paras = data.split(/\n\n+/) last_track = 0 last_numtrack = nil paras.each { |para| raw_fields = {} para.each_line { |line| next if line =~ /^#/ key, value = line.chomp.split(/\s*:\s*/, 2) unless value warn "Malformed line: #{line.chomp}, skipping" end if raw_fields[key] raw_fields[key] << "\n" << value else raw_fields[key] = value end } filename = raw_fields.delete("File") unless filename warn "No filename found for #{raw_fields}, skipping" next end begin TagLib.open(filename) { |file| tag = file.dwim_tag(true) old_fields = {} tag.each { |k,v| old_fields[k] = v } raw_fields.each { |key, value| if key == "Track" case value when %r{\A(\d+)/(\+d)\z} last_track = $1.to_i last_numtrack = $2.to_i when %r{\A(\d+)/\z} last_track = $1.to_i value = "#{last_track}/#{last_numtrack || paras.size}" when "++", "/" last_numtrack = paras.size if value == "/" last_track += 1 if last_numtrack value = "#{last_track}/#{last_numtrack}" else value = "#{last_track}" end end end tag[key] = value.split("\n") } new_fields = {} tag.each { |k,v| new_fields[k] = v } if !FLAGS["f"] && old_fields == new_fields warn "nothing changed in #{filename}" next end if FLAGS["n"] old_fields.each { |k,v| p [k,v] } tag.each { |k,v| p [k,v] } else file.dwim_save puts "Wrote #{filename}" end } rescue NotImplementedError warn $! end } end def getopt(param, argv=ARGV) opts={} while a = argv.first z = a.chars break if z.shift != "-" || a == "-" || argv.shift == "--" while (f = z.shift) && (param =~ /#{f}(:?)/ or abort "invalid flag -#{f}") if $1.empty? opts[f] = (opts[f] ||= 0) + 1 else opts[f] = (z.empty? ? argv.shift : z.join) or abort "missing parameter for -#{f}" break end end end opts end FLAGS = getopt "wnrif" case when FLAGS["w"] STDIN.set_encoding "BINARY" do_write(STDIN.read) when FLAGS["r"] do_read(ARGV) when FLAGS["i"], true # default Tempfile.open('tagvi') { |file| do_read(ARGV, file) file.rewind before = file.read system(ENV["VISUAL"] || ENV["EDITOR"] || "vi", file.path) file.rewind after = file.read if !$?.success? || after == "" || before == after abort "no changes" else do_write(after) end } end