CouchDBで快適録画生活 | 0.8.1 で作ったプログラムを0.10.1に更新

家の録画管理コマンドラインツールが塩漬けで放置されていたので、更新作業をしていました。ほぼスクラッチから作り直しで、しばらくかかりそうです。

requirement はこんな感じです。

  1. 特定の芸能人がでる番組は見る見ないに関わらず確実に補足。
  2. オンライン(iPhone)で予約可能。
  3. 見ると決めたドラマは1クール連続予約をしたい。
  4. 録画がおわったら再生のためにごにょごにょしておいてほしい。
  5. 最近ustを垂れ流していることもあるので、ust も"録画"しておきたい。

世の中いいツールがあるのですが、自分で何とかすることにします。特に1, 3辺りを解決しようとすると、TVっ子な自分の脳内チェックアルゴリズム(w を完全に実装する必要があるので既存のツールだとどうにもならない。。。

今度社内勉強会でCouchDBについて話すようなので、こんなアプリ作れるんだよ、という紹介に使おうかと思って作ってみました。

とりあえず、番組表ぐらいまで、1日で。

http://www.yssk22.info/recstore/_design/recstore/_list/lineup/iepg?startkey=%5B2010,3,14,4,0%5D&endkey=%5B2010,3,15,3,59%5D

以下実装について。

iEPGというガラパゴスデータ


番組表に使われるデータはiEPGというフォーマットのデータです。Sonyが作ったようですが、規格書らしきものは見あたりません。そして、大人の事情か何かわかりませんが、キー放送局では自局の番組に対するiEPGを公開していません。infoseek, goo, so-net, msg あたりのポータル系がiEPG情報を公開してくれていますが*1、各サイトの情報はマチマチです。

と、データソースはいろいろあります。昔の実装では、livedoorRSSフィードを使っていたのですが、昨年秋ぐらいに?なくなってしまったので、ポータルサイトから無理矢理引っこ抜く、という方針です。

で、1. の要求を満たすために infoseek を使っています。 infoseek (or MSN) の iEPG だとちゃんと出演者情報がとれます。24時間表示のHTMLからiepgへのリンクを正規表現で取り出して、GETでとってくる。

  • 毎朝 5:00 ぐらいに更新されるようなので、5:30ぐらいに1日分まとめて取りに行く
  • 録画予約を確認するのは主に日曜の夜なので、日曜の朝だけ1週間分まとめて取りに行く

これはcronでやります。実際に動いているのは restkit を利用するpythonスクリプト。前は net/http を使うrubyだったのですが、python 生活が続いているのでpythonです。restkitだとHTTPのくせにConnectionPool というのが使えてしまいますw ちゃんと確認していませんが、内部的にはKeepAliveの実装でしょうか。

それはともかく、iEPG

name: value
...
name: value
name: value

body

という単純な、それでいてガラパゴスなデータ構造(文字コードSJISだったり、デファクトデリミターが全角スペースだったり)をしているのですが、これを最小限のJSONに直してしまいます。例えばドラゴンボールだとこんな感じ。

{
   "_id": "51f916df190cd1939a3ae95a13911360",
   "_rev": "1-7805cb314fec7f87fabdb8a6461a39ba",
   "performer": "[声]野沢雅子 [声]田中真弓",
   "end": "09:30",
   "version": "1",
   "url": "http://tv.infoseek.co.jp/tvpi.epg?pg=tv_record.html&program=p0006100321090000&area=008",
   "type": "iepg",
   "memo": "▽スーパーサイヤ人と化した悟空にまるで歯が立たないフリーザ。彼は、自身が宇宙空間でも生き延びられるという特性を利用して、ナメック星を破壊しようと星そのものにエネルギー波を放つ。ナメック星の消滅まであと5分。サイヤ人とフリーザの決着のときが迫っていた。\r\n\r\n\r\n",
   "program-title": "ドラゴンボール改",
   "month": "03",
   "start": "09:00",
   "station": "フジテレビ",
   "year": "2010",
   "date": "21",
   "Content-type": "application/x-tv-program-info; charset=shift_jis",
   "program-subtitle": "仇を討て孫悟空!惑星崩壊のカウントダウン▽ドラゴン改"
}

memo というのは body のこと*2

で、このままだと扱いづらいのでMapReduce View を使って、使える形に変換してあげます。

番組の開始終了時刻

まず、番組の開始終了の時刻部分がファジーです。

正規化。特に end が start の前の時刻を指しているときは日付またぎ、と解釈するようです。48時間番組とかあったらどうするのかは謎。year, month, date は開始の時点の日付のようです。

   // start and end
   var start = new Date(doc.year  + "/" +
                        doc.month + "/" +
                        doc.date  + " " +
                        doc.start);
   var end   = new Date(doc.year  + "/" +
                        doc.month + "/" +
                        doc.date  + " " +
                        doc.end);

   if( start > end ){
      end.setDate(end.getDate() + 1);
   }
   doc.start_date = start;
   doc.end_date = end;

出演者情報

また、出演者情報も、全角スペースがデリミターで変なフラグのついたリストになっているので、これを直します。

   if(doc.performer){
      var list = doc.performer.split(" ");
      for(var i in list){
         var str = list[i];
         if( str.match(/\[(.+)\](.+)/) ){
            list[i] = {
               type : RegExp.$1,
               name : RegExp.$2
            };
         }else{
            list[i] = {
               name : str
            };
         }
      }
      doc.performer = list;
   }

これで type: "出" となっていれば出演者だとわかります。

チャンネル番号

これもiEPGのふざけたところで、station という放送局があるのですが、そちらは "フジテレビ" のように入ってしまっています。通常録画するときは、チャンネル番号という放送塔によって定義されるコード番号を使います。なので、これは設定ファイルとして、stationからチャンネル番号への変換が必要です。couchapp では設定ファイルをJSONで書いておけば、デプロイ時にアプリケーションに埋め込んでおくことができるので、JSONで記述します。APP_ROOT/mapping.json とします。

{
  "MXテレビ" : 20,
  "NHK教育" : 26,
  "NHK総合" : 27,
  "TBSテレビ" : 22,
  "TVKテレビ" : 18,
  "テレビ朝日" : 24,
  "テレビ東京" : 23,
  "フジテレビ" : 21
}

我が家は神奈川県なので上記のようなコードです。で、MapReduceの最中に、

   if( mapping[doc.station] ){
      doc.channel = mapping[doc.station];
   }else{
      return null;
   }

とやれば、iEPGで地域外の番組を拾ってきても大丈夫です。

以上のような形で、"正規化"する関数を normalize_iepg として、定義しておいてMapReduceの実行中にiEPGを正規化して使いやすくします。

さて、ここからクエリについて。

出演者クエリ

performer の正規化は済んだので、出演者で補足するためのMapReduceだと

function(doc){
   // !code lib/helper.js
   // !json mapping
   doc = normalize_iepg(doc, mapping);
   if( doc && doc.performer){
        doc.performer.forEach(function(p){
            if( p.type == "出"){ emit(p.name, doc);
        });
   }
}

としてやれば、簡単に見つけられます。

番組表

番組表はinfoseekのみりゃいいじゃん、ということで実装していなかったのですが、今のCouchDBでは、MapReduceで時系列に並び替えて、List でフォーマットできる、ということで実装しました。

まず、時系列並び替え。キー を [Year, Month, Day, Hour, Minute, Channel] としているので、特定時間帯のすべての番組を取得できます。

function(doc){
   // !code lib/helper.js
   // !json mapping
   doc = normalize_iepg(doc, mapping);
   if(doc){
      emit([
         doc.start_date.getFullYear(),
         doc.start_date.getMonth() + 1,
         doc.start_date.getDate(),
         doc.start_date.getHours(),
         doc.start_date.getMinutes(),
         doc.channel
      ], doc);
   }
}

で、これを、例えば24時間分番組表に出すには、3600(24 * 60)行ある表を用意して塗りつぶし判定をしながらレンダリングをする、という形でListを実装します。長いので解説はしませんが、貼っておきます。

表データを作るときは SQLのほうが遙かに楽そうに見えますね、、、、

function(head, req){
   // !code vendor/couchapp/path.js
   // !code vendor/crayon/lib/template.js
   // !code lib/helper.js
   // !code lib/lineup.js
   // !code include/bindings.js
   // !json templates.site.html
   // !json templates.lineup.html
   // !json mapping

   // parameter parsing
   if( req.query.startkey == undefined ||
       req.query.endkey   == undefined ){
      return start(redirectToday());
   }


   provides("html", function(){
      send(render(templates.site.html.header, bindings));
      send(render(templates.lineup.html.header, bindings));
      var channels = [];
      var lineups = {};
      var currentRIndex = {};

      p("<table><thead><tr>");
      p("<th>&nbsp;</th>");
      for(var k in mapping){
         send(render(templates.lineup.html.th,{
            "name" : k,
            "channel" : mapping[k]
         }));
         lineups[mapping[k]]       = [];
         currentRIndex[mapping[k]] = 0;
      }
      p("</tr></thead><tbody>");

      // table data building
      var row;
      var start = new Date(3000, 1, 1);
      var end   = new Date(1970, 1, 1);
      while(row = getRow()){
         var iepg = row.value;
         iepg.start_date = parse_date(iepg.start_date);
         iepg.end_date = parse_date(iepg.end_date);
         lineups[iepg.channel].push(iepg);
         if( iepg.start_date < start ){
            start = iepg.start_date;
         }
         if( iepg.end_date > end ){
            end = iepg.end_date;
         }
      }

      // rendering rows wich each minute
      var totalRows = (end - start) / 60000;
      var rIndex = 0;
      var rStartTime = start;
      var rowspan;

      while(rIndex < totalRows){
         p("<tr>");
         // row header
         if( rIndex == 0 ){
            rowspan = 60 - rStartTime.getMinutes();
            p("<td class=\"hrow\" rowspan=\"" + rowspan + "\">" + rStartTime.getHours() + "</td>");
         }else if( rStartTime.getMinutes() == 0 ){
            if( rStartTime.getDate() == end.getDate() &&
                rStartTime.getHours() == end.getHours() ){
               // remains
               rowspan = end.getMinutes() - rStartTime.getMinutes();
            }else{
               rowspan = 60;
            }
            p("<td class=\"hrow\" rowspan=\"" + rowspan + "\">" + rStartTime.getHours() + "</td>");
         }


         // writing each channel rows
         for(k in mapping){
            var ch = mapping[k];
            var list = lineups[ch];
            var program = list[0];
            var chIndex = currentRIndex[ch];
            if( chIndex == rIndex ){
               // rendering td tag with rowspan.
               if( program ){
                  if( rStartTime < program.start_date ){
                     // padding
                     rowspan = (program.start_date - rStartTime) / 60000;
                     send(render(templates.lineup.html.padding, {
                        rowspan : rowspan
                     }));
                  }else{
                     // insert program
                     rowspan = (program.end_date - program.start_date) / 60000;
                     send(render(templates.lineup.html.program, {
                        id: program._id,
                        title: program["program-title"],
                        link: program.url,
                        rowspan : rowspan,
                        start: program.start,
                        end: program.end
                     }));
                     // pop program from lineup stack
                     lineups[ch].shift();
                  }
               }else{
                  // padding last
                  rowspan = (end - rStartTime) / 60000;
                  send(render(templates.lineup.html.padding, {
                     rowspan : rowspan
                  }));
               }
               currentRIndex[ch] = chIndex + rowspan;
            }
         }
         p("</tr>");
         rIndex = rIndex + 1;
         rStartTime.setMinutes(rStartTime.getMinutes() + 1);
      }
      p("</tbody></table>");
      send(render(templates.lineup.html.footer, bindings));
      send(render(templates.site.html.footer, bindings));
      return;
   });
}

*1:なぜかYahooはないw

*2:どうもiEPGだとヘッダ部、メモ部と呼ぶらしいです