nsIWebBrowserPersistじゃなくてnsIChannelをnsIDownloadManager管理でダウンロードする

MySpaceのMP3ファイルにID3tagを埋め込みつつダウンロードするJSActionsスクリプト でやっているダウンロードしながらファイルを加工してその進行状況をダウンロードマネージャに表示する、っていうのが簡単じゃなかったのでご紹介。でもextensionっぽいの書いて2日目なので違うところも多々あると思うので間違ってたら指摘していただけるとうれしいです。

ふつうにダウンロードして保存するだけだったらこれですみます。

  var dm = Components.classes["@mozilla.org/download-manager;1"]
                .getService(Components.interfaces.nsIDownloadManager);
  var persist = Components.classes["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
                    .createInstance(Components.interfaces.nsIWebBrowserPersist);
  var download = dm.addDownload(0, sourceUri, targetUri, "downloading filename", null, null, null, tmpfile, persist);

  persist.progressListener = download;
  persist.saveURI(sourceUri, null, null, null, null, targetFile);

でもsaveURIを呼ぶと中が全部隠蔽されちゃって手出しができないので中身を加工しつつ保存したかったらnsIChannelを使わないといけないです。だけどnsIDownloadManagernsDownloadManager.cpp を見ると

  // if a persist was provided, we can do the cancel ourselves.
  nsCOMPtr persist;
  download->GetPersist(getter_AddRefs(persist));
  if (persist) {
    rv = persist->CancelSave();
    if (NS_FAILED(rv)) return rv;
  }

こんなになっててnsIWebBrowserPersist以外は面倒見る気がないのがわかる。だからそのへんをごまかしてあげないとダウンロードマネージャでちゃんと管理してもらえない。

ダウンロードマネージャに登録

channel経由でダウンロードするときでもダウンロードマネージャに表示させるにはまずaddDownloadします。最後の引数はnsICancelableを渡さないといけないけどnsIWebBrowserPersistを使わないのでとりあえずかわりにnullを渡しておきます。とりあえずこれで問題ありません。(これだとキャンセルが正しく処理できなくなるのであとで差し替えます)

var download = dm.addDownload(0, sourceUri, targetUri, "downloading filename", null, null, null, tmpfile, null);

nsIStreamListerを作る

nsIChannelでダウンロードするときは

channel.asyncOpen(listener, context);

を使う。そうするとチャネルからデータを読み出したりしたときにlistenerにイベントが送られてくる。Interface Reference – nsIChannelによるとlstenernsIStreamListnerで、これをどうやって作るのかがわかんなかったんだけど、コールバックで呼ばれるのはonStartRequest, onDataAvailable, onStopRequestの3つだけなのでこれらを定義したオブジェクトを渡せばだいじょうぶ。

var listener = {
        onStartRequest: function ( request, context ) {        },
        onDataAvailable: function ( request, context ,  inputStream ,  offset ,  count)  {        },
        onEndTransfer: function (request) {        },
};

こんなlistenerを渡しとけば問題なしです。

ストリームの保存

ファイルをそのまま保存するんだったらバイナリストリームにするのがよさそうです。
はじめバイナリストリームにしないで読み込んでたらバイナリファイルのなかにゼロが出てくるところでデータが落ちててはまりまくりました。

        onDataAvailable: function ( request, context ,  inputStream ,  offset ,  count)  {
            this.total_downloaded += count;
            var bstream = Components.classes["@mozilla.org/binaryinputstream;1"]
                        .createInstance(Components.interfaces.nsIBinaryInputStream);
            bstream.setInputStream(inputStream);
            var bytes = bstream.readBytes(bstream.available());
            this.o_stream.write(bytes, bytes.length);

            request.QueryInterface(Components.interfaces.nsIHttpChannel);
            this.download.onProgressChange64(null, request,
                    this.total_downloaded, request.contentLength,
                    this.total_downloaded, request.contentLength
            );

データを書き出したらダウンロードマネージャのプログレスバーをアップデートしてあげます。requestからnsIHttpChannelを取得できるので、これを使ってContent-Lengthを取り出します。目標数値と現在数値を渡すと向こう側でパーセントにして計算してくれるのでバイト数をそのまま渡せばいいです。Content-Lengthがない場合はとりあえず考えてません。

ストリームの加工

id3v23はファイルのアタマにちょこっと書くだけなので、リクエストのはじめに呼ばれるonStartRequest

        onStartRequest: function ( request, context ) {
            var tag = MP3.ID3v2.implant( {
                TPE1: artist_name,
                TIT2: song_title
            } );
            this.o_stream.write(tag, tag.length);
        },

ちょこっと書いてるだけです。これでほんもののファイルのアタマにID3tagを埋め込めます。

終了通知

転送が終わるとonStopRequestが呼ばれるので後始末をして、ダウンロードマネージャにダウンロードが終わったことを通知してあげます。closeし忘れるとファイルがexclusive writeでオープンされたままになったりしてfirefoxを終了させないと触れなくなったりして困るので気をつけましょう。onStateChangeは第一引数にnsIWebProgressを要求してますがnullで大丈夫みたいです。

        onStopRequest: function ( request, context ,  statusCode ) {
            if ( this.o_stream ) {
                this.o_stream.flush();
                this.o_stream.close();
                this.o_stream = null;
            }
            this.download.onStateChange(null, request, this.download.STATE_STOP,
                Components.results.NS_OK);
        }

キャンセルのハンドリング

で、ふつうにダウンロードするだけならこれで問題なく動きます。が、ダウンロードマネージャでファイルのダウンロードをキャンセルすると、ダウンロードマネージャ上ではキャンセルしたことになっていてでも実際にはキャンセルされてなくてへんなことになったりします。
はじめに見たようにダウンロードマネージャはnsIWebBrowserPersistを持ってないとキャンセルしたことを通知してくれないのでnsICancelableを持ってるオブジェクトを作ります。ダウンロードマネージャはキャンセルされるとnsICancelableにキャンセルされたことを通知した後すぐにファイルを消しにかかるので、キャンセル通知を受け取ったらファイルを閉じてファイルを消去可能な状態にしてあげないといけません。

どう実装してもいいですが、お手軽なのでlisterにインターフェイスを追加しました。nsIWebBrowserPersistを持っていないとキャンセル通知を送ってくれないダウンロードマネージャに通知を送ってもらえるようにnsIWebBrowserPersistを追加して、そのキャンセル通知を受け取れるようにnsICancelableも付加します。(nsIWebBrowserPersistがあれば必要ないかも。ためしてません)

var listener = {
        QueryInterface : function(aIID) {
            trace("QI: " + aIID);
            if (aIID.equals(Components.interfaces.nsISupports) ||
                aIID.equals(Components.interfaces.nsIStreamListener) ||
                aIID.equals(Components.interfaces.nsICancelable) ||
                aIID.equals(Components.interfaces.nsIWebBrowserPersist)) {
                return this;
            } else {
                throw Components.results.NS_NOINTERFACE;
            }
        },
        // nsICancelable
        cancel: function ( ) {
            this.canceling = true;
        },
        // nsIWebBrowserPersist
        CancelSave: function () {        },
        .....
}

これでlistnernsIWebBrowserPersistとして扱ってもらえるようになるので

var download = dm.addDownload(0, sourceUri, targetUri, "downloading filename", null, null, null, tmpfile, listener);

に置き換えるとダウンロードがキャンセルされたときにlistenercancelが呼び出されるようになります。上のコードでは省略してますが、このタイミングでファイルを閉じてあげれば、ダウンロード途中のテンポラリファイルはダウンロードマネージャの側で削除してくれます。
なんかchannelもダウンロードマネージャ側で閉じてくれるみたいで、キャンセルした時点でnetstatしてみてみてもコネクションが無くなっていました。

かんそう

ふつうにダウンロードして後から加工すればっていう話もありましたがそれだとつまんないのでいろいろ小細工をしてみました。ファイルを加工しながらダウンロードしたいことはあんまりなさそうですが QueryInterface を定義して勝手にほかのもののふりをするというテクニックは(Creating Sandboxed HTTP Connections – MDCに載ってました)いろんなところで使えそう。


About this entry