diff --git a/README.rst b/README.rst index 6469f48c..954c412a 100644 --- a/README.rst +++ b/README.rst @@ -57,6 +57,11 @@ the playlist object used in the for loop above has a few attributes: - ``uri``: the url to the stream - ``stream_info``: a ``StreamInfo`` object (actually a namedtuple) with all the attributes available to `#EXT-X-STREAM-INF`_ +- ``media``: a list of related ``Media`` objects with all the attributes + available to `#EXT-X-MEDIA`_ +- ``playlist_type``: the type of the playlist, which can be one of `VOD`_ + (video on demand) or `EVENT`_ + **NOTE: the following attributes are not implemented yet**, follow `issue 4`_ for updates @@ -66,7 +71,7 @@ the playlist object used in the for loop above has a few attributes: with all the attribute available to `#EXT-X-I-FRAME-STREAM-INF`_ - ``alternative_audios``: its an empty list, unless it's a playlist with `Alternative audio`_, in this case it's a list with ``Media`` - objects with all the attributes available to `#X-EXT-MEDIA`_ + objects with all the attributes available to `#EXT-X-MEDIA`_ - ``alternative_videos``: same as ``alternative_audios`` Running Tests @@ -98,4 +103,6 @@ the same thing. .. _I-Frames: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.13 .. _#EXT-X-I-FRAME-STREAM-INF: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.13 .. _Alternative audio: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-8.7 -.. _#X-EXT-MEDIA: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.9 +.. _#EXT-X-MEDIA: http://tools.ietf.org/html/draft-pantos-http-live-streaming-08#section-3.4.9 +.. _VOD: https://developer.apple.com/library/mac/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-TNTAG2 +.. _EVENT: https://developer.apple.com/library/mac/technotes/tn2288/_index.html#//apple_ref/doc/uid/DTS40012238-CH1-EVENT_PLAYLIST diff --git a/m3u8/__init__.py b/m3u8/__init__.py index 7150fd6a..9f231e40 100644 --- a/m3u8/__init__.py +++ b/m3u8/__init__.py @@ -3,10 +3,10 @@ import urlparse from urllib2 import urlopen -from m3u8.model import M3U8, Playlist +from m3u8.model import M3U8, Playlist, Media from m3u8.parser import parse, is_url -__all__ = 'M3U8', 'Playlist', 'loads', 'load', 'parse' +__all__ = 'M3U8', 'Playlist', 'Media', 'loads', 'load', 'parse' def loads(content): ''' diff --git a/m3u8/model.py b/m3u8/model.py index 9f9987e4..4802b89b 100644 --- a/m3u8/model.py +++ b/m3u8/model.py @@ -53,6 +53,14 @@ class M3U8(object): If this is a variant playlist (`is_variant` is True), returns a list of Playlist objects + `playlist_type` + A lower-case string representing the type of the playlist, which can be + one of VOD (video on demand) or EVENT. + + `media` + If this is a variant playlist (`is_variant` is True), returns a list of + Media objects + `target_duration` Returns the EXT-X-TARGETDURATION as an integer http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.2 @@ -85,6 +93,7 @@ class M3U8(object): ('media_sequence', 'media_sequence'), ('version', 'version'), ('allow_cache', 'allow_cache'), + ('playlist_type', 'playlist_type') ) def __init__(self, content=None, base_path=None, base_uri=None): @@ -109,9 +118,25 @@ def _initialize_attributes(self): self.files.append(self.key.uri) self.files.extend(self.segments.uri) - self.playlists = PlaylistList([ Playlist(base_uri=self.base_uri, **playlist) + self.media = [] + for media in self.data.get('media', []): + self.media.append(Media(uri=media.get('uri'), + type=media.get('type'), + group_id=media.get('group_id'), + language=media.get('language'), + name=media.get('name'), + default=media.get('default'), + autoselect=media.get('autoselect'), + forced=media.get('forced'), + characteristics=media.get('characteristics'))) + + self.playlists = PlaylistList([ Playlist(base_uri=self.base_uri, + media=self.media, + **playlist) for playlist in self.data.get('playlists', []) ]) + + def __unicode__(self): return self.dumps() @@ -145,13 +170,16 @@ def add_playlist(self, playlist): self.is_variant = True self.playlists.append(playlist) + def add_media(self, media): + self.media.append(media) + def dumps(self): ''' Returns the current m3u8 as a string. You could also use unicode() or str() ''' output = ['#EXTM3U'] - if self.media_sequence: + if self.media_sequence is not None: output.append('#EXT-X-MEDIA-SEQUENCE:' + str(self.media_sequence)) if self.allow_cache: output.append('#EXT-X-ALLOW-CACHE:' + self.allow_cache.upper()) @@ -161,7 +189,33 @@ def dumps(self): output.append(str(self.key)) if self.target_duration: output.append('#EXT-X-TARGETDURATION:' + int_or_float_to_string(self.target_duration)) + 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_variant: + for media in self.media: + media_out = [] + + if media.uri: + media_out.append('URI=' + quoted(media.uri)) + if media.type: + media_out.append('TYPE=' + media.type) + if media.group_id: + media_out.append('GROUP-ID=' + quoted(media.group_id)) + if media.language: + media_out.append('LANGUAGE=' + quoted(media.language)) + if media.name: + media_out.append('NAME=' + quoted(media.name)) + if media.default: + media_out.append('DEFAULT=' + media.default) + if media.autoselect: + media_out.append('AUTOSELECT=' + media.autoselect) + if media.forced: + media_out.append('FORCED=' + media.forced) + if media.characteristics: + media_out.append('CHARACTERISTICS=' + quoted(media.characteristics)) + + output.append('#EXT-X-MEDIA:' + ','.join(media_out)) output.append(str(self.playlists)) output.append(str(self.segments)) @@ -304,12 +358,17 @@ def __str__(self): class Playlist(BasePathMixin): ''' Playlist object representing a link to a variant M3U8 with a specific bitrate. - Each `stream_info` attribute has: `program_id`, `bandwidth`, `resolution` and `codecs` - `resolution` is a tuple (h, v) of integers + + Attributes: + + `stream_info` is a named tuple containing the attributes: `program_id`, + `bandwidth`,`resolution`, `codecs` and `resolution` which is a a tuple (w, h) of integers + + `media` is a list of related Media entries. More info: http://tools.ietf.org/html/draft-pantos-http-live-streaming-07#section-3.3.10 ''' - def __init__(self, uri, stream_info, base_uri): + def __init__(self, uri, stream_info, media, base_uri): self.uri = uri self.base_uri = base_uri @@ -324,6 +383,13 @@ def __init__(self, uri, stream_info, base_uri): program_id=stream_info.get('program_id'), resolution=resolution_pair, codecs=stream_info.get('codecs')) + self.media = [] + for media_type in ('audio', 'video', 'subtitles'): + group_id = stream_info.get(media_type) + if not group_id: + continue + + self.media += filter(lambda m: m.group_id == group_id, media) def __str__(self): stream_inf = [] @@ -336,9 +402,16 @@ def __str__(self): stream_inf.append('RESOLUTION=' + res) if self.stream_info.codecs: stream_inf.append('CODECS=' + quoted(self.stream_info.codecs)) + + for media in self.media: + media_type = media.type.upper() + stream_inf.append('%s="%s"' % (media_type, media.group_id)) + return '#EXT-X-STREAM-INF:' + ','.join(stream_inf) + '\n' + self.uri StreamInfo = namedtuple('StreamInfo', ['bandwidth', 'program_id', 'resolution', 'codecs']) +Media = namedtuple('Media', ['uri', 'type', 'group_id', 'language', 'name', + 'default', 'autoselect', 'forced', 'characteristics']) class PlaylistList(list, GroupedBasePathMixin): diff --git a/m3u8/parser.py b/m3u8/parser.py index c8946285..e70478bf 100644 --- a/m3u8/parser.py +++ b/m3u8/parser.py @@ -7,6 +7,8 @@ ext_x_targetduration = '#EXT-X-TARGETDURATION' ext_x_media_sequence = '#EXT-X-MEDIA-SEQUENCE' +ext_x_media = '#EXT-X-MEDIA' +ext_x_playlist_type = '#EXT-X-PLAYLIST-TYPE' ext_x_key = '#EXT-X-KEY' ext_x_stream_inf = '#EXT-X-STREAM-INF' ext_x_version = '#EXT-X-VERSION' @@ -27,8 +29,10 @@ def parse(content): data = { 'is_variant': False, 'is_endlist': False, + 'playlist_type': None, 'playlists': [], 'segments': [], + 'media': [], } state = { @@ -67,6 +71,12 @@ def parse(content): state['expect_playlist'] = True _parse_stream_inf(line, data, state) + elif line.startswith(ext_x_media): + _parse_media(line, data, state) + + elif line.startswith(ext_x_playlist_type): + _parse_simple_parameter(line, data) + elif line.startswith(ext_x_endlist): data['is_endlist'] = True @@ -88,19 +98,30 @@ def _parse_ts_chunk(line, data, state): segment['uri'] = line data['segments'].append(segment) -def _parse_stream_inf(line, data, state): - params = ATTRIBUTELISTPATTERN.split(line.replace(ext_x_stream_inf + ':', ''))[1::2] +def _parse_attribute_list(prefix, line, quoted): + params = ATTRIBUTELISTPATTERN.split(line.replace(prefix + ':', ''))[1::2] - stream_info = {} + attributes = {} for param in params: name, value = param.split('=', 1) - stream_info[normalize_attribute(name)] = value + name = normalize_attribute(name) - if 'codecs' in stream_info: - stream_info['codecs'] = remove_quotes(stream_info['codecs']) + if name in quoted: + value = remove_quotes(value) + attributes[name] = value + + return attributes + +def _parse_stream_inf(line, data, state): data['is_variant'] = True - state['stream_info'] = stream_info + quoted = ('codecs', 'audio', 'video', 'subtitles') + state['stream_info'] = _parse_attribute_list(ext_x_stream_inf, line, quoted) + +def _parse_media(line, data, state): + quoted = ('uri', 'group_id', 'language', 'name', 'characteristics') + media = _parse_attribute_list(ext_x_media, line, quoted) + data['media'].append(media) def _parse_variant_playlist(line, data, state): playlist = {'uri': line, @@ -136,4 +157,4 @@ def normalize_attribute(attribute): return attribute.replace('-', '_').lower().strip() def is_url(uri): - return re.match(r'https?://', uri) is not None \ No newline at end of file + return re.match(r'https?://', uri) is not None diff --git a/tests/playlists.py b/tests/playlists.py index c89ce75b..2fe19e92 100644 --- a/tests/playlists.py +++ b/tests/playlists.py @@ -98,6 +98,22 @@ index_0_a.m3u8?e=b471643725c47acd ''' +SIMPLE_PLAYLIST_WITH_VOD_PLAYLIST_TYPE = ''' +#EXTM3U +#EXT-X-PLAYLIST-TYPE:VOD +#EXTINF:180.00000, +some_video.ts +#EXT-X-ENDLIST +''' + +SIMPLE_PLAYLIST_WITH_EVENT_PLAYLIST_TYPE = ''' +#EXTM3U +#EXT-X-PLAYLIST-TYPE:EVENT +#EXTINF:180.00000, +some_video.ts +#EXT-X-ENDLIST +''' + RELATIVE_PLAYLIST_FILENAME = abspath(join(dirname(__file__), 'playlists/relative-playlist.m3u8')) RELATIVE_PLAYLIST_URI = TEST_HOST + '/path/to/relative-playlist.m3u8' diff --git a/tests/test_model.py b/tests/test_model.py index 87a6a00b..c64a3f91 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -105,12 +105,19 @@ def test_playlists_attribute(): obj = m3u8.M3U8(SIMPLE_PLAYLIST) data = {'playlists': [{'uri': '/url/1.m3u8', 'stream_info': {'program_id': '1', - 'bandwidth': '320000'}}, + 'bandwidth': '320000', + 'video': 'high'}}, {'uri': '/url/2.m3u8', 'stream_info': {'program_id': '1', 'bandwidth': '120000', - 'codecs': 'mp4a.40.5'}}, - ]} + 'codecs': 'mp4a.40.5', + 'video': 'low'}}, + ], + 'media': [{'type': 'VIDEO', 'name': 'High', 'group_id': 'high'}, + {'type': 'VIDEO', 'name': 'Low', 'group_id': 'low', + 'default': 'YES', 'autoselect': 'YES'} + ] + } mock_parser_data(obj, data) assert 2 == len(obj.playlists) @@ -120,11 +127,31 @@ def test_playlists_attribute(): assert '320000' == obj.playlists[0].stream_info.bandwidth assert None == obj.playlists[0].stream_info.codecs + assert None == obj.playlists[0].media[0].uri + assert 'high' == obj.playlists[0].media[0].group_id + assert 'VIDEO' == obj.playlists[0].media[0].type + assert None == obj.playlists[0].media[0].language + assert 'High' == obj.playlists[0].media[0].name + assert None == obj.playlists[0].media[0].default + assert None == obj.playlists[0].media[0].autoselect + assert None == obj.playlists[0].media[0].forced + assert None == obj.playlists[0].media[0].characteristics + assert '/url/2.m3u8' == obj.playlists[1].uri assert '1' == obj.playlists[1].stream_info.program_id assert '120000' == obj.playlists[1].stream_info.bandwidth assert 'mp4a.40.5' == obj.playlists[1].stream_info.codecs + assert None == obj.playlists[1].media[0].uri + assert 'low' == obj.playlists[1].media[0].group_id + assert 'VIDEO' == obj.playlists[1].media[0].type + assert None == obj.playlists[1].media[0].language + assert 'Low' == obj.playlists[1].media[0].name + assert 'YES' == obj.playlists[1].media[0].default + assert 'YES' == obj.playlists[1].media[0].autoselect + assert None == obj.playlists[1].media[0].forced + assert None == obj.playlists[1].media[0].characteristics + def test_playlists_attribute_without_program_id(): obj = m3u8.M3U8(SIMPLE_PLAYLIST) mock_parser_data(obj, {'playlists': [{'uri': '/url/1.m3u8', @@ -171,6 +198,18 @@ def test_files_attribute_should_list_all_files_including_segments_and_key(): ] assert files == obj.files +def test_vod_playlist_type_should_be_imported_as_a_simple_attribute(): + obj = m3u8.M3U8(SIMPLE_PLAYLIST_WITH_VOD_PLAYLIST_TYPE) + assert obj.playlist_type == 'vod' + +def test_event_playlist_type_should_be_imported_as_a_simple_attribute(): + obj = m3u8.M3U8(SIMPLE_PLAYLIST_WITH_EVENT_PLAYLIST_TYPE) + assert obj.playlist_type == 'event' + +def test_no_playlist_type_leaves_attribute_empty(): + obj = m3u8.M3U8(SIMPLE_PLAYLIST) + assert obj.playlist_type is None + # dump m3u8 @@ -282,6 +321,41 @@ def test_should_normalize_segments_and_key_urls_if_base_path_attribute_updated() assert obj.dumps().strip() == expected +def test_playlist_type_dumped_to_appropriate_m3u8_field(): + obj = m3u8.M3U8() + obj.playlist_type = 'vod' + result = obj.dumps() + expected = '#EXTM3U\n#EXT-X-PLAYLIST-TYPE:VOD\n' + assert result == expected + +def test_empty_playlist_type_is_gracefully_ignored(): + obj = m3u8.M3U8() + obj.playlist_type = '' + result = obj.dumps() + expected = '#EXTM3U\n' + assert result == expected + +def test_none_playlist_type_is_gracefully_ignored(): + obj = m3u8.M3U8() + obj.playlist_type = None + result = obj.dumps() + expected = '#EXTM3U\n' + assert result == expected + +def test_0_media_sequence_added_to_file(): + obj = m3u8.M3U8() + obj.media_sequence = 0 + result = obj.dumps() + expected = '#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:0\n' + assert result == expected + +def test_none_media_sequence_gracefully_ignored(): + obj = m3u8.M3U8() + obj.media_sequence = None + result = obj.dumps() + expected = '#EXTM3U\n' + assert result == expected + def test_should_correctly_update_base_path_if_its_blank(): segment = Segment('entire.ts', 'http://1.2/') assert not segment.base_path diff --git a/tests/test_variant_m3u8.py b/tests/test_variant_m3u8.py index cffcd535..f1ab44a2 100644 --- a/tests/test_variant_m3u8.py +++ b/tests/test_variant_m3u8.py @@ -1,18 +1,24 @@ import m3u8 def test_create_a_variant_m3u8_with_two_playlists(): - low_playlist = m3u8.Playlist('http://example.com/low.m3u8', stream_info={'bandwidth': '1280000', 'program_id': '1'}, base_uri=None) - high_playlist = m3u8.Playlist('http://example.com/high.m3u8', stream_info={'bandwidth': '3000000', 'program_id': '1'}, base_uri=None) - variant_m3u8 = m3u8.M3U8() + + subtitles = m3u8.Media('english_sub.m3u8', 'SUBTITLES', 'subs', 'en', + 'English', 'YES', 'YES', 'NO', None) + variant_m3u8.add_media(subtitles) + + low_playlist = m3u8.Playlist('http://example.com/low.m3u8', stream_info={'bandwidth': '1280000', 'program_id': '1', 'subtitles': 'subs'}, media=[subtitles], base_uri=None) + high_playlist = m3u8.Playlist('http://example.com/high.m3u8', stream_info={'bandwidth': '3000000', 'program_id': '1', 'subtitles': 'subs'}, media=[subtitles], base_uri=None) + variant_m3u8.add_playlist(low_playlist) variant_m3u8.add_playlist(high_playlist) expected_content = """\ #EXTM3U -#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000 +#EXT-X-MEDIA:URI="english_sub.m3u8",TYPE=SUBTITLES,GROUP-ID="subs",LANGUAGE="en",NAME="English",DEFAULT=YES,AUTOSELECT=YES,FORCED=NO +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,SUBTITLES="subs" http://example.com/low.m3u8 -#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000 +#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=3000000,SUBTITLES="subs" http://example.com/high.m3u8 """ assert expected_content == variant_m3u8.dumps()