podcast の新着を Winamp に追加する

最初に (Common) Lisp 入門として Scheme を学んだ頃には想像すらしなかった事態なんですが、今や Scheme でプログラムを書くのが私にとってすごく自然で楽しい行為になってきています。

元は Lisp ぎらいだったんですが、嫌いな食べ物が突然食べられるようになる、みたいな内的変化があったのかも知れません。もう S 式以外でプログラムを書きたくない、と思うぐらいです。


そこで、最近の日常的な Scheme プログラミングの中から、いかに Scheme が日常のプログラミングが得意かを見ていただこうと思います。podcast で配信されている番組を Winamp のプレイリスト (m3u ファイル) に追加する、という例を取り上げます。

戦略としては、RSS の解析と M3U データの読み込みという、2つの部分に分けて考えていきます。

M3U データのロード

M3U は基本的にこういう形式で2行ずつ情報が並んでいるテキスト・ファイルです:

#EXTINF:123,Song Title
path/to/mp3

(数字は秒数)
が、まだプレイしたことが無いファイルはパスの1行のみの場合があるので要注意です。

新着だけを追加したいという目的から、既に M3U データにある mp3 は追加しないようにするため、M3U ファイル全体の情報を知っておく必要があります。

したがって、M3U ファイルの全項目をハッシュ・テーブルに格納してしまう方法を取ります (PLT Scheme を使っています):

(define (iter-m3u m3u k)
  (with-input-from-file m3u
    (lambda ()
      (let ((info #f)
            (time (lambda (m)
                    (string->number
                     (bytes->string/latin-1 (cadr m)))))
            (title caddr))
        (port-for-each (lambda (ln)
                         (cond ((match-info ln) =>
                                (lambda (m)
                                  (set! info (cons (time m)
                                                   (title m)))))
                               ((match-mp3 ln) =>
                                (lambda (m)
                                  (let ((path (car m)))
                                    (if info
                                        (begin
                                          (k path info)
                                          (set! info #f))
                                        (k path (cons -1 path))))))))
                       read-bytes-line)))
    'text))

(define (load-m3u m3u)
  (let ((mdb (make-hash-table 'equal)))
    (iter-m3u m3u
              (lambda (url info)
                (hash-table-put! mdb url info)))
    mdb))

port-for-each というイテレーション関数に基づいて、さらにイテレーション関数を作っています (iter-m3u)。この抽象化により、ロードのための関数を非常に簡潔に書くことが出来ました。後で使うのはこの load-m3u だけです。

サポート関数:

;; Cited from: http://community.schemewiki.org/?port-utilities
(define (port-for-each fn reader)
  (let loop ()
    (let ((item (reader)))
      (cond ((not (eof-object? item))
             (fn item)
             (loop))))))

(define match-mp3
  (let ((rx #rx#"^.+\\.(?i:mp3)$"))
    (lambda (s)
      (regexp-match rx s))))

(define match-info
  (let ((rx #rx#"^#EXTINF:([-0-9]+),(.*)$"))
    (lambda (s)
      (regexp-match rx s))))

match-* 関数では正規表現オブジェクトを lambda の外側に置いてキャッシュしています。イテレーションの度にオブジェクトを生成しないようにするためです。

いわゆるクロージャなんですが、変数定義と関数定義の間に区別が無い Scheme ならではの手法と言えるかも知れません。

XML データの解析

XMLLisp とは相性が良いことで知られており、XML の文書構造は見た目の上でも殆ど同じ形で S 式で表すことができます (SXML と呼びます)。

英国 BBC ラジオの配信を例に、RSS の記事 (item) をひとつ覗いてみると:

;; From: http://downloads.bbc.co.uk/podcasts/radio4/today/rss.xml
(item
  (title "Today: 0845 Environmental Literature 24 Sep 07")
  (description "Can literature deal effectively with modern ...")
  (itunes:subtitle "Can literature deal effectively with modern ...")
  (itunes:summary "Can literature deal effectively with modern ...")
  (pubDate "Mon, 24 Sep 2007 11:20:00 +0100")
  (itunes:duration "3:44")
  (enclosure
    (@ (url "http://downloads.bbc.co.uk/podcasts/radio4/today/today_20070924-1120.mp3")
       (type "audio/mpeg")
       (length "1841089")))
  (guid
    (@ (isPermaLink "false"))
    "http://downloads.bbc.co.uk/podcasts/radio4/today/today_20070924-1120.mp3")
  (link "http://downloads.bbc.co.uk/podcasts/radio4/today/today_20070924-1120.mp3")
  (media:content
    (@ (url "http://downloads.bbc.co.uk/podcasts/radio4/today/today_20070924-1120.mp3")
       (type "audio/mpeg")
       (medium "audio")
       (fileSize "1841089")
       (expression "full")
       (duration "224")))
  (itunes:author "BBC Radio 4"))

のような感じになっています。このうち M3U に書き込みたい情報は URL とプレイ時間、タイトル、ついでに著者です。

URL が何箇所もあったりして迷うんですが、media:content 要素の属性に都合よく URL と時間の情報があるので、これを利用したいと思います。

(require (planet "ssax.ss" ("lizorkin" "ssax.plt"))
         (planet "sxml.ss" ("lizorkin" "sxml.plt")))

(define (parse-pod url)
  ((sxpath
    `(rss channel item
          ,(lambda (items _)
             (let ((nodes (sxpath '((*or* title itunes:author))))
                   (params (sxpath '(media:content
                                     @ (*or* url duration)))))
               (map (lambda (item)
                      (let* ((item (append! (nodes item)
                                            (params item)))
                             (find (lambda (n)
                                     (sxml:string
                                      (sxml:lookup n item)))))
                        (lambda (n)
                          (case n
                            ((author) (find 'itunes:author))
                            (else (find n))))))
                    items)))))
   (ssax:xml->sxml
    (open-input-resource url)
    '((media . "http://search.yahoo.com/mrss/")
      (itunes . "http://www.itunes.com/dtds/podcast-1.0.dtd")))))

parse-pod 関数の下のほうのかたまりが XML ドキュメントを取得して SXML 化する部分です。上のほうのかたまりがそれを解析します。

sxpath というのが「SXML から要素を抽出する関数」を作る関数です。rss 要素の子の channel 要素の子の item 要素を (全て) 取り出して、それを lambda に渡すという流れになっています。

各 item に対しては

(sxpath '((*or* title itunes:author)))

を適用することで、title と itunes:author の両方のノードが得られます (*or* なのでどちらか一方と勘違いしそうですが)。ノードの属性を得るには "@" を使って

(sxpath '(media:content @ (*or* url duration)))

とします。

さらに、後から値を取り出しやすいように、各 item 要素をメッセージ渡しスタイルのオブジェクト (クロージャ) にしています。したがって、この parse-pod 関数の返り値は関数のリストです。

テストしてみると:

(for-each
 (lambda (item)
   (printf "#EXTINF:~a,~a - ~a~%~a~%"
           (item 'duration)
           (item 'author)
           (item 'title)
           (item 'url)))
 (parse-pod
  "http://downloads.bbc.co.uk/podcasts/radio4/today/rss.xml"))
=>
#EXTINF:429,BBC Radio 4 - Today: 0850 Brown Speech 25 Sep 07
http://downloads.bbc.co.uk/podcasts/radio4/today/today_20070925-0944.mp3
#EXTINF:634,BBC Radio 4 - Today: 0750 V.S Naipaul 25 Sep 07
http://downloads.bbc.co.uk/podcasts/radio4/today/today_20070925-0923.mp3
#EXTINF:609,BBC Radio 4 - Today: 0810 Burma 25 Sep 07
http://downloads.bbc.co.uk/podcasts/radio4/today/today_20070925-0907.mp3
#EXTINF:224,BBC Radio 4 - Today: 0845 Environmental Literature 24 Sep 07
http://downloads.bbc.co.uk/podcasts/radio4/today/today_20070924-1120.mp3
...

バッチリです。

仕上げ

load-m3u と parse-pod を組み合わせていきます。

RSS の解析結果から M3U データベースを照会して、新着のみを抜き出す関数 extract-new がごく手短に書けます。

(define (extract-new items mdb)
  (filter (lambda (item)
            (not
             (hash-table-get mdb        ;mdb holds url as byte-string
                             (string->bytes/latin-1 (item 'url))
                             #f)))
          items))

(define (pod>>m3u url m3u)
  (let ((new (extract-new (parse-pod url)
                          (load-m3u m3u))))
    (unless (null? new)
      (call-with-output-file m3u
        (lambda (out)
          (for-each (lambda (item)
                      (printf "Added: ~a - ~a~%" (item 'author) (item 'title))
                      (fprintf out "#EXTINF:~a,~a - ~a~%~a~%"
                              (item 'duration)
                              (item 'author)
                              (item 'title)
                              (item 'url)))
                    (reverse! new)))
        'append 'text))))

余談ですが、小さな関数を (個別にテストしつつ) 着実に組み立ててアプリケーションを作っていく、というのが Lisp 系のプログラミングの楽しさの本質のような気がします。個人的な経験に照らすと、粘土細工を作る感覚に近いです。

このように実行します:

(pod>>m3u "http://downloads.bbc.co.uk/podcasts/radio4/today/rss.xml"
          "C:/Program Files/Winamp/Winamp.m3u8")

media と itunes という拡張タグ両方に依拠しているのであまり汎用的ではないですが、ふとしたアイデアを即座に具体化できる手軽さを感じて頂ければ嬉しいです。