LoginSignup
24
23

More than 5 years have passed since last update.

PythonでHLS動画形式のm3u8ファイルを操作するライブラリ m3u8 のコードリーディング

Last updated at Posted at 2015-12-14

この記事は 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

24
23
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
23