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の他にoggflacもまとめて扱えるようだったので、これを使いました。

…と、万事これでうまくいくかと思いきや、ruby-audioinfo内部で呼んでいるruby-mp3infoもid3tagの文字コード関係は鬼門で、調べてみるとid3v2は実質的にutf-8しか扱えないようです。結局ファイルからタグを読んだ時点で強引にkconvutf-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)