plist

MacOS Xではplistというファイル形式でちょっとした永続化を行うことが多い。 iTunesのプレイリストもその一つ。

OSXのplist

iTunesの出力するプレイリストから、曲名とアーティストを印字したかったので ちょっと書いてみた。須藤さんのページを眺めながらREXML初挑戦。

そういえばplistのdtdを見つけた。

dataを実装してないから、そのうち書こう。

iTunesのライブラリを全て処理すると時間がかかるのでXMLScanを 使った実装(scan_plist.rb)も用意してみた。こっちは3倍くらい速い。

plist.rb

plistを解釈するライブラリって既にありそう。 今回利用しないdateはめんどくさかったのでFIXME。

  • レシピ本を読んだらTime.parseを見つけたのでとりあえずそれを使うようにした。
  • Peter McMasterさんからコメントをもらったので、ちょっと反映した。
# plist.rb
require 'rexml/document'
require 'time'

class Plist
   def self.file_to_plist(fname)
     File.open(fname) do |fp|
       doc = REXML::Document.new(fp)
       return self.new.visit(REXML::XPath.match(doc, '/plist/')[0])
     end
   end

   def initialize
     setup_method_table
   end

   def visit(node)
     visit_one(node.elements[1])
   end

   def visit_one(node)
     choose_method(node.name).call(node)
   end

   def visit_null(node)
     p node if $DEBUG
     nil
   end

   def visit_dict(node)
     dict = {}
     es =  node.elements.to_a
     while key = es.shift
       next unless key.name == 'key'
       dict[key.text] = visit_one(es.shift)
     end
     dict
   end

   def visit_array(node)
     node.elements.collect do |x|
       visit_one(x)
     end
   end

   def visit_integer(node)
     node.text.to_i
   end

   def visit_real(node)
     node.text.to_f
   end

   def visit_string(node)
     node.text.to_s
   end

   def visit_date(node)
     Time.parse(node.text.to_s)
   end

   def visit_true(node)
     true
   end

   def visit_false(node)
     false
   end

   private
   def choose_method(name)
     @method.fetch(name, method(:visit_null))
   end

   def setup_method_table
     @method = {}
     @method['dict'] = method(:visit_dict)
     @method['integer'] = method(:visit_integer)
     @method['real'] = method(:visit_real)
     @method['string'] = method(:visit_string)
     @method['date'] = method(:visit_date)
     @method['true'] = method(:visit_true)
     @method['false'] = method(:visit_false)
     @method['array'] = method(:visit_array)
   end
end

playlist.rb

MacOSXのplistをHash, ArrayなどRubyのオブジェクトで表現してから プレイリストを取り出すことにした。

# playlist.rb
require 'plist'

class MyITunesLibrary
   Song = Struct.new(:track_id, :name, :artist, :album)

   class Song
     def self.new_with_dict(dict)
       self.new(dict['Track ID'], dict['Name'], dict['Artist'], dict['Album'])
     end
   end

   class Playlist
     def self.new_with_dict(library, dict)
       playlist = self.new(dict['Name'])

       dict['Playlist Items'].each do |track|
         track_id  = track['Track ID']
         playlist.add_track(library[track_id])
       end

       playlist
     end

     def initialize(name)
       @name = name
       @track = []
     end
     attr_reader :name, :track

     def add_track(song)
       @track << song
     end
   end

   def initialize
     @song = Hash.new
     @playlist = Hash.new
   end
   attr_reader :song, :playlist

   def [](track_id)
     @song[track_id]
   end

   def add_song(song)
     @song[song.track_id] = song
   end

   def add_playlist(playlist)
     @playlist[playlist.name] = playlist
   end

   def import_plist(plist)
     plist['Tracks'].each do | track, dict |
       add_song(Song.new_with_dict(dict))
     end

     plist['Playlists'].each do | dict |
       add_playlist(Playlist.new_with_dict(self, dict))
     end
   end
end

def main
  plist = Plist.file_to_plist(ARGV.shift)

  lib = MyITunesLibrary.new
  lib.import_plist(plist)

  lib.playlist.each do |name, it|
    puts name
    it.track.each_with_index do |x, idx|
      printf("%3d. %s / %s", idx + 1, x.name, x.artist)
      puts
    end
  end
end

main

scan_plist.rb

iTunesのライブラリを読み込むのに数分かかる。 REXMLの代わりに[RAA:XMLScan]を使って時間を測ってみた

# scan_plist.rb
require 'xmlscan/parser'
require 'time'

class Plist
  class Visitor
    include XMLScan::Visitor

    def initialize
      @stack = []
      @preserve = false
    end
    attr_reader :stack

    def on_stag(name)
      sym = name.intern
      push(sym)
      @preserve = [:string, :key, :data].include?(sym)
    end

    def on_chardata(str)
      return if !@preserve && /^\s*$/ =~ str
      if tail.class == String
        str = pop + str
      end
      push(str)
    end

    def on_stag_end_empty(name)
      @preserve = false
      case name
      when 'true'
        eval_true
      when 'false'
        eval_true
      end
    end

    def on_etag(name)
      @preserve = false
      case name
      when 'dict'
        eval_dict
      when 'integer'
        eval_integer
      when 'real'
        eval_real
      when 'string'
        eval_string
      when 'date'
        eval_date
      when 'array'
        eval_array
      when 'key'
        eval_key
      when 'plist'
        eval_plist
      end
    end

    def eval_integer
      value = pop.to_i
      pop_stag(:integer)
      push(value)
    end

    def eval_real
      value = pop.to_f
      pop_stag(:real)
      push(value)
    end

    def eval_string
      value = pop_str
      pop_stag(:string)
      push(value)
    end

    def eval_key
      value = pop
      pop_stag(:key)
      push(value)
    end

    def eval_date
      value = Time.parse(pop)
      pop_stag(:date)
      push(value)
    end

    def eval_true
      pop_stag(:true)
      push(true)
    end

    def eval_false
      pop_stag(:false)
      push(false)
    end

    def eval_array
      value = []
      while v = pop
        break if v == :array
        value.push(v)
      end
      push(value)
    end

    def eval_dict
      value = {}
      while v = pop
        break if v == :dict
        k = pop
        break if k == :dict #FIXME
        value[k] = v
      end
      push(value)
    end

    def eval_plist
      value = pop
      pop_stag(:plist)
      push(value)
    end

    def push(obj)
      @stack.push(obj)
    end

    def pop
      @stack.pop
    end

    def pop_str
      (tail.class == String) ? pop : ''
    end

    def pop_stag(sym)
      return pop unless $DEBUG
      v = pop
      raise([sym, v].inspect) unless v == sym
      return v
    end

    def tail
      @stack[-1]
    end
  end

  def self.file_to_plist(fname)
    File.open(fname) do |f|
      visitor = Visitor.new
      parser = XMLScan::XMLParser.new(visitor)
      parser.parse(f)
      visitor.tail
    end
  end
end