mpd(Music Player Daemon)の互換データベースをRubyで出力する
mpd(Music Player Daemon)はLinux/UNIXでmp3/ogg/flac等の音楽を再生するデーモンです。音楽プレイヤとしては珍しくインターフェースになるクライアントと、音楽再生を行うデーモンから成るクライアント/サーバの構成を取っており、GTKを使うような通常のGUIクライアント(sonataなど)の他にコマンドライン(mpc/ncmcpなど)やWeb用(phpMpなど)の様々なクライアントが開発されています。
このように大変魅力的(?)なソフトウェアなのですが、日本語を含むid3tagはバージョンを問わずshift-jisでエンコーディングされていることが多く、このようなケースではmpd --update-dbして作成したデータベースは0xc2や0xc3が混ざった不可解な変換がされ文字化けしてしまいます。(※id3v1はmpd.confのid3v1_encodingで指定可能ですが、これだけだと実用上あまりうれしくありません)
mpdに自力でパッチを作ろうかとも思ったのですが、ソースを読んでみたら想像より面倒くさそうだったので、使い慣れたRubyでmpd互換のtag_cacheを吐き出すスクリプトを書くことにしました。流行っていないようですがgemにあったruby-audioinfoというライブラリがmp3の他にoggやflacもまとめて扱えるようだったので、これを使いました。
…と、万事これでうまくいくかと思いきや、ruby-audioinfo内部で呼んでいるruby-mp3infoもid3tagの文字コード関係は鬼門で、調べてみるとid3v2は実質的にutf-8しか扱えないようです。結局ファイルからタグを読んだ時点で強引にkconvでutf-8に変換するよういくつかのメソッドをコピペして再定義せざるを得ませんでした。
かなり酷いスクリプトではありますが、どうにかこれでmpd互換のデータベースが文字化けせずに出力できています。もっと良い方法がありそうなのですが…うーん(*_*)。
#mpd互換のtag_cacheデータベースを標準出力するスクリプト require 'rubygems' require 'audioinfo' #gem install ruby-audioinfo --remote require 'kconv' #TODO:mpd.confのmusic_directoryを指定 MUSIC_DIR = "" raise "set MUSIC_DIR !" if MUSIC_DIR == "" #ここから機能拡張と文字化け防止のため再定義 #それぞれ以下のバージョンが前提、コピペなのでこれ以外だと動作しない #ruby-audioinfo-0.1.4 #ruby-mp3info-0.6.10 #AudioInfoの再定義 #ジャンルを取得できるようにするため class AudioInfo attr_reader :genre #ジャンルを追加 def initialize(fn, encoding = 'utf-8') raise(AudioInfoError, "path is nil") if fn.nil? @path = fn ext = File.extname(@path) raise(AudioInfoError, "cannot find extension") if ext.empty? @extension = ext[1..-1].downcase @musicbrainz_infos = {} @encoding = encoding begin case @extension when 'mp3' @info = Mp3Info.new(fn, :encoding => @encoding) default_tag_fill if (arr = @info.tag2["TXXX"]).is_a?(Array) fields = MUSICBRAINZ_FIELDS.invert arr.each do |val| if val =~ /^MusicBrainz (.+)\000(.*)$/ short_name = fields[$1] @musicbrainz_infos[short_name] = $2 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 @genre = @info.tag["genre_s"] #ジャンルを追加 @info.close when 'ogg' @info = OggInfo.new(fn, @encoding) 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(fn) mpc_info = MpcInfo.new(fn) @bitrate = mpc_info.infos['bitrate']/1000 @length = mpc_info.infos['length'] when 'ape' fill_ape_tag(fn) when 'wma' @info = WmaInfo.new(fn, :encoding => @encoding) @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 'aac', 'mp4', 'm4a' @info = MP4Info.open(fn) @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 -i #{fn.shell_escape} 2>&1 `.grep(/^MusicBrainz (.+)$/) do name, value = $1.split(/: /, 2) key = mapping[name] @musicbrainz_infos[key] = value end when 'flac' @info = FlacInfo.new(fn) tags = convert_tags_encoding(@info.tags, "UTF-8") @artist = tags["ARTIST"] @album = tags["ALBUM"] @title = tags["TITLE"] @tracknum = tags["TRACKNUMBER"].to_i @date = tags["DATE"] @length = @info.streaminfo["total_samples"] / @info.streaminfo["samplerate"].to_f @bitrate = File.size(fn).to_f*8/@length/1024 else raise(AudioInfoError, "unsupported extension '.#{@extension}'") end if @tracknum == 0 @tracknum = nil end @musicbrainz_infos.delete_if { |k, v| v.nil? } @hash = { "artist" => @artist, "album" => @album, "title" => @title, "tracknum" => @tracknum, "date" => @date, "length" => @length, "bitrate" => @bitrate, } rescue Exception, Mp3InfoError, OggInfoError, ApeTagError => e raise AudioInfoError, e.to_s, e.backtrace end @needs_commit = false end end #MP3Info関連の再定義 #文字化け防止のため読んだ端からUTF8化する class Mp3Info private def gettag1 @tag1_parsed = true #@tag1["title"] = @file.read(30).unpack("A*").first @tag1["title"] = @file.read(30).toutf8 #読み込み時にUTF8化 #@tag1["artist"] = @file.read(30).unpack("A*").first @tag1["artist"] = @file.read(30).toutf8 #読み込み時にUTF8化 #@tag1["album"] = @file.read(30).unpack("A*").first @tag1["album"] = @file.read(30).toutf8 #読み込み時にUTF8化 year_t = @file.read(4).to_i @tag1["year"] = year_t unless year_t == 0 comments = @file.read(30) if comments.getbyte(-2) == 0 @tag1["tracknum"] = comments.getbyte(-1).to_i comments.chop! #remove the last char end @tag1["comments"] = comments.unpack("A*").first @tag1["genre"] = @file.getbyte @tag1["genre_s"] = GENRES[@tag1["genre"]] || "" # clear empty tags @tag1.delete_if { |k, v| v.respond_to?(:empty?) && v.empty? } @tag1.delete("genre") if @tag1["genre"] == 255 @tag1.delete("tracknum") if @tag1["tracknum"] == 0 end end #ID3v2の場合の処理 class ID3v2 ### Read a tag from file and perform UNICODE translation if needed def decode_tag(name, raw_value) puts("decode_tag(#{name.inspect}, #{raw_value.inspect})") if $DEBUG case name when "COMM" #FIXME improve this encoding, lang, str = raw_value.unpack("ca3a*") out = raw_value.split(0.chr).last when /^T/ encoding = raw_value.getbyte(0) # language encoding (see TEXT_ENCODINGS constant) out = raw_value[1..-1] # we need to convert the string in order to match # the requested encoding if out && encoding != @text_encoding_index begin #Iconv.iconv(@options[:encoding], TEXT_ENCODINGS[encoding], out).first out.toutf8 #決めうちでUTF8化 rescue Iconv::Failure out end else out end else raw_value end end end #再定義ここまで #music_directory内でのパスを整形 def format_relpath(path) if path[0,1] != "/" return path else return path[1..-1] end end #songListのエントリを出力する def print_song_list_entry(path, path_in_music = "") info_entry = "" info_entry << "key: " + path + "\n" file = format_relpath(path_in_music + '/' + path) info_entry << "file: " + file + "\n" begin AudioInfo.open(path){|info| info_entry << "Time: " + info.length.to_s + "\n" info_entry << "Artist: " + info.artist.to_s + "\n" if info.artist info_entry << "Title: " + info.title.to_s + "\n" if info.title info_entry << "Album: " + info.album.to_s + "\n" if info.album info_entry << "Track: " + info.tracknum.to_s + "\n" if info.tracknum info_entry << "Genre: " + info.genre.to_s + "\n" if info.genre } info_entry << "mtime: " + File.mtime(path).to_i.to_s + "\n" rescue AudioInfoError #非対応のファイル形式も例外で捕捉する STDERR.puts(file) STDERR.puts($!) return end print info_entry end #ディレクトリを辿ってリストを出力 def search_directory(path, path_in_music = "") old_dir = Dir.pwd dir = Dir.open(path) Dir.chdir(path) file_list = [] dir_list = [] #ディレクトリとファイルの一覧を作成 dir.each{|item| next if item.match(/^\./) #ドットファイルは読み飛ばす if FileTest.directory?(item) dir_list << item else file_list << item end } #ディレクトリをソートして再帰的に辿る dir_list.sort.each{|dir| puts "directory: " + dir relpath = format_relpath(path_in_music + '/' + dir) puts "begin: " + relpath search_directory(dir, relpath) puts "end: " + relpath } #ファイルをsongListにして出力する puts "songList begin" file_list.sort.each{|file| print_song_list_entry(file, path_in_music) } puts "songList end" Dir.chdir(old_dir) end #info出力(定型) info =<<INFO info_begin mpd_version: 0.13.2 fs_charset: UTF-8 info_end INFO print info #リスト出力 search_directory(MUSIC_DIR)