この記事は adventer の方の Python Advent Calendar 2015 の14日目の記事です。 (Qiitaの方にも別の Python Advent Calenderがあります)
最近諸事情で動画関連のコードを触ることがありm3u8パッケージを使ったので、m3u8をコードリーディングしてみます。
HTTP Live Streaming
HLS (HTTP Live Streaming) は動画のストリーミング配信のプロトコルです。 通常のmp4などの動画は1つの動画が1つのファイルに書き込まれています。
HLSは動画を分割してあり、それをダウンロードしながら再生します。HLSで動画を配信するときに関連するファイルは3つあります。
種類 | 拡張子 | 内容 |
---|---|---|
tsファイル | ts | 実際の動画データです。通常複数あります。 |
m3u8ファイル | m3u8 | 動画データをどの順番に再生すれば良いかなどの動画のメタデータを保持します。 |
鍵 | 特になし | tsファイルを暗号化している際の複合鍵です。 |
m3u8ファイルの中に別のm3u8ファイルを指定できたりもします(が、今回はそのことは書きませせん)。
m3u8ファイルとm3u8ライブラリ
例えばこんな形式のテキストファイルです。
#EXTM3U
#EXT-X-KEY:METHOD=AES-256,URI="http://example.com/keyfile",IV=000000000000000
#EXTINF:2,"aaa"
http://example.com/1.ts
#EXTINF:2,
http://example.com/2.ts
#EXTINF:2,
http://example.com/3.ts
#EXTINF:2,
http://example.com/4.ts
当然ですが動的に生成したくなります。例えば鍵ファイルはtsファイルのURLを一時的なURLにしたm3u8ファイルを生成したりなどです。そんなときに使えるライブラリが m3u8 です。今回はこのm3u8ライブラリをコードリーディングします。
Github: https://github.com/globocom/m3u8
PyPI: https://pypi.python.org/pypi/m3u8
インストール手順と使い方(本当にさわりだけ)
使い方はREADMEなどにちゃんと書かれているので、ここでは本当にさわりだけやります。
インストール
::
$ pip install m3u8
普通に入ります。
iso8601 に依存しているようです。
https://github.com/globocom/m3u8/blob/master/requirements.txt#L1
iso8601は日付と時刻の表記の規格です。
使い方
とりあえずimportしてみます。
>>> import m3u8
以降、Pythonのインタラクティブシェルには既にm3u8がimportされている状態とします。
ファイルの解析
先ほどのm3u8ファイルがplaylist.m3u8という名前でカレントディレクトリにあるとします。
$ cat playlist.m3u8
#EXTM3U
#EXT-X-KEY:METHOD=AES-256,URI="http://example.com/keyfile",IV=000000000000000
#EXTINF:2,"aaa"
http://example.com/1.ts
#EXTINF:2,
http://example.com/2.ts
#EXTINF:2,
http://example.com/3.ts
#EXTINF:2,
http://example.com/4.ts
$
このファイルをインタラクティブシェル上で読み込んでみます。
>>> playlist = m3u8.load('./playlist.m3u8')
['METHOD=AES-256', 'URI="http://example.com/keyfile"', 'IV=000000000000000']
>>> playlist
<m3u8.model.M3U8 object at 0x1024b97f0>
m3u8.model.M3U8 オブジェクトが返されます。
各動画URLには.segmentsでアクセスできます。
>>> playlist.segments
[<m3u8.model.Segment object at 0x10292f3c8>, <m3u8.model.Segment object at 0x10292f400>, <m3u8.model.Segment object at 0x10292f4a8>, <m3u8.model.Segment object at 0x10292f518>]
属性などを操作することで、読み込んだ情報を変更し出力できます。
ファイルへの書き出し
先ほどのplaylistオブジェクトをoutput.m3u8というファイルに書き出してみます。
>>> playlist.dump('./output.m3u8')
出力を確認します。
$ cat output.m3u8
#EXTM3U
#EXT-X-KEY:METHOD=AES-256,URI="http://example.com/keyfile",IV=000000000000000
#EXTINF:2,"aaa"
http://example.com/1.ts
#EXTINF:2,
http://example.com/2.ts
#EXTINF:2,
http://example.com/3.ts
#EXTINF:2,
http://example.com/4.ts% $
出力されています。
コードリーディング
全体を見渡す
とりあえずリポジトリの全体構成を確認します。
$ tree
.
├── LICENSE
├── MANIFEST.in
├── README.rst
├── m3u8
│ ├── __init__.py
│ ├── model.py
│ ├── parser.py
│ └── protocol.py
├── requirements-dev.txt
├── requirements.txt
├── runtests
├── setup.py
└── tests
├── m3u8server.py
├── playlists
│ ├── relative-playlist.m3u8
│ └── simple-playlist.m3u8
├── playlists.py
├── test_loader.py
├── test_model.py
├── test_parser.py
├── test_strict_validations.py
└── test_variant_m3u8.py
3 directories, 20 files
ふむふむ、実際のソースは4つしかなさそうです。それらのサイズはこんな感じです。
$ wc -l m3u8/*.py
75 m3u8/__init__.py
674 m3u8/model.py
261 m3u8/parser.py
22 m3u8/protocol.py
1032 total
全部で1000行強っぽいですね。
load()/loads()
先ほど使ったloadからコードを掘り返していくことにします。
m3u8.load()
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/__init__.py#L35-L43
if is_url(uri):
return _load_from_uri(uri)
else:
return _load_from_file(uri)
内部でuriがurlかどうかをチェックして処理を分岐しているっぽいです。先ほど通ったのは _load_from_file(uri) の方でしょう。
URLでも指定できそうなのでやってみます。
>>> m3u8.load('https://gist.githubusercontent.com/TakesxiSximada/04189f4f191f55edae90/raw/1ecab692886508db0877c0f8531bd1f455f83795/m3u8%2520example')
<m3u8.model.M3U8 object at 0x1076b3ba8>
おお、できるっぽいですね。urlopen()で取得しているようです。
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/__init__.py#L46-L53
ちなみにm3u8.loads()もあるので文字列からM3U8 オブジェクトを生成することもできます。
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/__init__.py#L28-L33
>>> playlist_str = '#EXTM3U\n#EXT-X-KEY:METHOD=AES-256,URI="http://example.com/keyfile",IV=000000000000000\n#EXTINF:2,"aaa"\nhttp://example.com/1.ts\n#EXTINF:2,\nhttp://example.com/2.ts\n#EXTINF:2,\nhttp://example.com/3.ts\n#EXTINF:2,\nhttp://example.com/4.ts'
>>> m3u8.loads(playlist_str)
<m3u8.model.M3U8 object at 0x1076cfef0>
m3u8.model.M3U8()
load()やloads()ではM3U8オブジェクトを返しています。
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/__init__.py#L33
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/__init__.py#L53
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/__init__.py#L74
M3U8クラスはm3u8.model内で定義されています。
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/model.py#L19
M3U8クラスにはdump()だのdumps()だのが定義されていて、ファイルや文字列に書き出すことができます。関数名などjsonモジュールっぽいですが、ファイルオブジェクトは渡せなそうです。
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/model.py#L217-L271
dump()はディレクトリがない場合は作ってくれます。割と気が効いています。
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/model.py#L260
M3U8のインスタンスは次の属性を持っています。
属性名 | 型 |
---|---|
key | m3u8.model.Key |
segments | m3u8.model.SegmentList |
media | m3u8.model.MediaList |
playlists | m3u8.model.PlaylistList (名前w) |
iframe_playlists | m3u8.model.PlaylistList (これもか...) |
なんか若干名付けに悩んだ感じのところもありますが、主要なのはこれらです。
dumps()処理ではこれらの属性から値があったら書くみたいなことをやっています。
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/model.py#L222-L254
output = ['#EXTM3U']
if self.is_independent_segments:
output.append('#EXT-X-INDEPENDENT-SEGMENTS')
if self.media_sequence > 0:
output.append('#EXT-X-MEDIA-SEQUENCE:' + str(self.media_sequence))
if self.allow_cache:
output.append('#EXT-X-ALLOW-CACHE:' + self.allow_cache.upper())
if self.version:
output.append('#EXT-X-VERSION:' + self.version)
if self.key:
output.append(str(self.key))
if self.target_duration:
output.append('#EXT-X-TARGETDURATION:' + int_or_float_to_string(self.target_duration))
if self.program_date_time is not None:
output.append('#EXT-X-PROGRAM-DATE-TIME:' + parser.format_date_time(self.program_date_time))
if not (self.playlist_type is None or self.playlist_type == ''):
output.append(
'#EXT-X-PLAYLIST-TYPE:%s' % str(self.playlist_type).upper())
if self.is_i_frames_only:
output.append('#EXT-X-I-FRAMES-ONLY')
if self.is_variant:
if self.media:
output.append(str(self.media))
output.append(str(self.playlists))
if self.iframe_playlists:
output.append(str(self.iframe_playlists))
ここを見ているとself.keyやself.playlistsなどはstr()で文字列化できるっぽいですね。
m3u8.mdoel.Key()
m3u8フォーマットのEXT-X-KEYの値を管理するクラスです。(ivはイニシャルベクタ)
例えば動的に鍵を生成したり切り替えたりした場合は次のようにすれば出力されるm3u8ファイルに反映されます。
>>> m3uobj = m3u8.model.M3U8()
>>> print(m3uobj.dumps())
#EXTM3U
>>> key = m3u8.model.Key(method='AES-256', uri='http://example.com/key.bin', base_uri='', iv='0000000')
>>> str(key)
'#EXT-X-KEY:METHOD=AES-256,URI="http://example.com/key.bin",IV=0000000'
>>> m3uobj.key = key
>>> print(m3uobj.dumps())
#EXTM3U
#EXT-X-KEY:METHOD=AES-256,URI="http://example.com/key.bin",IV=0000000
m3u8.model.SegmentList()
M3U8クラスのインスタンスの.segmentsはリストではなくて、m3u8.model.SegmentListというList Likeなオブジェクトです。このクラスはlist型を継承しています。
>>> type(playlist.segments)
<class 'm3u8.model.SegmentList'>
>>> isinstance(playlist.segments, list)
True
要素にはm3u8.model.Segment()を入れます。
m3u8.model.Segment()
tsファイルの指定に使うクラスです。uriやbase_uriを指定します。またdurationを渡す必要があります。これはそのtsファイルを再生させる長さで単位は秒です(少数使えます)。
>>> m3uobj = m3u8.model.M3U8()
>>> m3uobj.segments.append(m3u8.model.Segment(uri='http://example.com/1.ts', base_uri='', duration=1))
>>> m3uobj.segments.append(m3u8.model.Segment(uri='http://example.com/2.ts', base_uri='', duration=1))
>>> print(m3uobj.dumps())
#EXTM3U
#EXTINF:1,
http://example.com/1.ts
#EXTINF:1,
http://example.com/2.ts
ちなみにコンストラクタで渡しているdurationはコンストラクタではduration=Noneで省略可能なパラメータのくせに、省略した状態でdumps()するとTypeErrorをraiseしてきます。
https://github.com/globocom/m3u8/blob/210db9c494c1b703ab7e169d3ae4ed488ec30eac/m3u8/model.py#L369
ここはあまり良くないところですね。
base_uriってなんだっけ?
ここまでにbase_uriをコンストラクタに渡してきました。今までこれは一体何のためにあるのだろうと思いつつふわっと使ってしまっていたのでついでに調べます。
base_uriはm3u8.model.BasePathMixinで使われています。
このクラスは Segment, Key, Playlist, IFramePlaylist, MediaにMixinされています。
@property
def absolute_uri(self):
if self.uri is None:
return None
if parser.is_url(self.uri):
return self.uri
else:
if self.base_uri is None:
raise ValueError('There can not be `absolute_uri` with no `base_uri` set')
return _urijoin(self.base_uri, self.uri)
これらは.absolute_uriプロパティを持っています。self.uriがURLの形式かどうかで挙動を変えています。URLの場合はそのままself.uriを使い、URLの形式でない場合(ファイルパスの形式など)は self.base_uriとself.uriをくっつけてabsolute_uriの値にしています。
URLかどうかはm3u8.parser.is_url()を使って判定しています。
先頭がhttp://もしくはhttps://で始まるものはURLと判定されます。
def is_url(uri):
return re.match(r'https?://', uri) is not None
base_uriが使われるケースを試す
SegmentにuriにURLでない値とbase_uriにURLっぽい値を指定します。
>>> m3uobj = m3u8.model.M3U8()
>>> m3uobj.segments.append(m3u8.model.Segment(uri='1.ts', base_uri='http://example.com', duration=1))
このコードで生成されるテキストを確認してみます。
>>> print(m3uobj.dumps())
#EXTM3U
#EXTINF:1,
1.ts
...absolute_uri、使われていません。grepしてもこれを使っているところは、test以外はありませんでした。中で使うものではなく外向けのAPIって位置付けなのかな....
base_pathでsegmentとかのpathを一括更新
base_uriとは対照的にbase_pathは結構使える子ちゃんです。これもm3u8.model.BasePathMixinで定義されています。segmentなどのuriを書き換えたいときにM3U8オブジェクトの値を書き換えると、M3U8オブジェクトが持っている属性のsegmentsやらkeyやらの値も書き換えてくれます。
例としてbase_pathを与えていない状態でdumps()してみます。
>>> m3uobj = m3u8.model.M3U8()
>>> m3uobj.segments.append(m3u8.model.Segment(uri='1.ts', base_uri='', duration=1))
>>> print(m3uobj.dumps())
#EXTM3U
#EXTINF:1,
1.ts
1.tsはSegment()生成時に渡したuriが使われています。次にm3uobj.base_pathにURLを入れてdumps()してみます。
>>> m3uobj.base_path = 'http://example.com'
>>> print(m3uobj.dumps())
#EXTM3U
#EXTINF:1,
http://example.com/1.ts
すると1.tsにbase_pathがついた状態で出力されました。base_pathはproperty/setterとして実装されていて、値を設定すると、uriの値をbase_pathをつけた状態でuriプロパティに設定し直しています。
@base_path.setter
def base_path(self, newbase_path):
if not self.base_path:
self.uri = "%s/%s" % (newbase_path, self.uri)
self.uri = self.uri.replace(self.base_path, newbase_path)
まとめ
ごめんなさい。力尽きました。ただmediaとかplaylistsとかも基本同じような構成になっていて、どういう値を生成するかぐらいが違う程度です。気が向いたら、続きを更新するかも。。
明日は @ininsanus さんですね。よろしくオナシャス!!
http://www.adventar.org/calendars/846#list-2015-12-15