HTTPのRangeアクセスを実装して、分割ダウンロードに対応する
auケータイの動画ファイルダウンロード(3gpp2とamc)するときにハマったのでメモ書きしておきます。
ガラパゴス携帯の動画再生
日本の3大キャリアの携帯では、動画をHTML上に埋め込む時にそれぞれ違った実装方法を取らないと行けない。ここではサーバとのやり取りに集中したいので、HTMLタグの詳細は省略します。
softbank
HTMLの中に <a>タグを使ってファイルのURLを書くだけ。通常のリンクと同じです。
データダウンロードは一括で行う。
au
HTMLの中に <object>タグを使ってファイル名、MIMEタイプ、携帯に保存する際の名前、ファイルサイズ(!?)などを記述します。
データダウンロードは HTTP 1.1のRangeを使った分割ダウンロードで行う
どうも au はHTML上にファイルサイズが必要だったり、仕様が行けてません。コンテンツ管理の利便性とかシステムの作りまで踏み込んだ仕様策定が行われていない気がします。
そしてシステムとして作り込むときに弊害になるであろう、分割ダウンロードでコンテンツを取得しています。Rangeアクセスは HTTP 1.1の範囲だけど、Rangeアクセスが必須ならば、KDDIの技術情報ページに書いておいて欲しかった。
HTTPのRangeってどういうもの?
RangeはHTTPのヘッダーのことで、クライアントがGETリクエストを送るときにデータの何バイト目から何バイト目までレスポンスで返してね、と指定するヘッダーになっています。
具体的には
Range: bytes=0-499
こんなヘッダーがクライアントから送られてきます。これの意味は、見たままで0番目から499番目までの500バイトを返してね、という要求です。
逆にサーバが返すレスポンスには
Content-Length: 500 Content-Ranges: 0-499/1000
という応答が入っています。ファイル全体の1000バイトだとした時に、Content-Lengthはこのレスポンスが返した500バイトが入ります。Content-Rangesヘッダーは、分割されたデータの情報です。要求のあった 0-499の範囲のデータだと示す情報と後ろの /1000 はデータ全体の情報となります。
ちなみにサーバのHTTPステータスコードは 200 ではなく、分割ダウンロードの場合は 206になります。ご注意ください。
他にもクライアントからの要求は複数のデータ範囲を指定出来て、レスポンス時に multipartデータとして一度に返す仕様もHTTP 1.1の中にあります。でも、話がややこしくなるので、ここでは省略します。
サーバのRangeアクセス対応状況の確認
このように通常とは異なるリクエストヘッダー、レスポンスヘッダーを含むので、事前にサーバ側がRangeアクセスに対応しているか確認する必要があります。サーバ側が次のようなヘッダー情報を返せば、Rangeアクセスしてもいいよ、とクライアントに通知できます。
Accept-Ranges: bytes
auの携帯では、ちゃんと確認しているのか怪しいですが。。。
auの動画再生時の流れ
では実際のところ、auの携帯ではどんな通信をしているか、Apacheのログを元に流れを追ってみます。
(User-Agentの後ろにリクエストのRangeヘッダーの内容を出力するようにカスタマイズしてます)
"HEAD /sample.3g2 HTTP/1.1" 200 - "-" "KDDI-KC3V UP.Browser/6.2.0.15.1.1 (GUI) MMP/2.0" "-" "GET /sample.3g2 HTTP/1.1" 206 129536 "-" "KDDI-KC3V UP.Browser/6.2.0.15.1.1 (GUI) MMP/2.0" "bytes=0-129535" "GET /sample.3g2 HTTP/1.1" 206 2050 "-" "KDDI-KC3V UP.Browser/6.2.0.15.1.1 (GUI) MMP/2.0" "bytes=129536-131585"
最初に HEAD でヘッダー情報だけを取得して、その後にRangeアクセスしていることが分かります。
ここから、システムとして作り込むのが必要になるのは次の2つの処理です。
- HEADメソッドに対応して、レスポンスヘッダーだけを返せるようにする(この時にAccept-Rangesヘッダーも返せるようにする)
- Rangeヘッダーに対応して、データ分割処理をする。
Django上でRangeアクセスを実装する
必要な機能がわかったら、いよいよプログラムを書いてみます。
def download_movie(request, filename): movie_data, last_modified = load_movie_data(filename) data_length = len(movie_data) if request.method == 'HEAD': response = HttpResponse(mimetype='video/3gpp2') response['Accept-Ranges'] = 'bytes' response['Content-Length'] = str(data_length) response['Pragma'] = 'no-cache' response['Expires'] = '-1' response['Last-Modified'] = last_modified.strftime('%a, %d %b %Y %H:%M:%S +0000') return response if request.method == 'GET': range_header = request.META.get('HTTP_RANGE') if range_header: bytes_pos = range_header.split('=')[-1].split('-') offset = int(bytes_pos[0]) lastest = int(bytes_pos[1]) if lastest > data_length: lastest = data_length movie_data = movie_data[offset:lastest] range_length = len(movie_data) response = HttpResponse(movie_data, status=206, mimetype='video/3gpp2') response['Accept-Ranges'] = 'bytes' response['Content-Length'] = str(range_length) response['Content-Range'] = 'bytes %s-%s/%s' % (str(offset), str(lastest), str(data_length)) response['Pragma'] = 'no-cache' response['Expires'] = '-1' response['Last-Modified'] = last_modified.strftime('%a, %d %b %Y %H:%M:%S +0000') return response
少し手抜きなので、Rangeヘッダーが見つからなかったときは一括でデータを返すのと、Rangeヘッダーから情報を取るときにもうちょっと厳密なフォーマットチェックをしたほうが良いかなと。。。
関数の最初で
movie_data, last_modified = load_movie_data(filename)
としているのは、movie_data は list を、last_modified は datetime を想定しています。
その前提が分かれば、そんなに難しくないかなと思います。
あとは、Last-Modifiedは、同じコンテンツだったら同じ日時を返さないと誤動作の原因になるかもしれないです。
そのほかの注意点
auケータイの場合は、極端にキャッシュを使うようになっているようで、ローカルキャッシュを強力につかってきます。しかも判別方法が酷くて、Last-ModifiedやETagをちゃんと設定してもURLが同一だと、サーバアクセスに来なかったりします。
それを避けるために、パラメータではなくコンテキストの途中に動的な数値を入れるなどしないと、毎回ダウンロードさせるなどは難しいかもしれません。ほんとにこの動作は... (ry