diff --git a/README.rst b/README.rst index f16d7357..c7aae382 100644 --- a/README.rst +++ b/README.rst @@ -84,6 +84,8 @@ Supported tags * `#EXT-X-SESSION-DATA`_ * `#EXT-X-DATERANGE`_ * `#EXT-X-GAP`_ +* `#EXTTV` +* extra tags Encryption keys --------------- @@ -232,6 +234,17 @@ you need to pass a function to the `load/loads` functions, following the example m3u8_obj = m3u8.load('http://videoserver.com/playlist.m3u8', custom_tags_parser=get_movie) print(m3u8_obj.data['movie']) # million dollar baby +Alternately, if you don't want to bother during parsing, all custom tags will be collected in the ``extra_tags`` field on +the Segment. This makes the list ignore, but respect custom tags. Therefore, doing the following will produce the same +list (custom tags **will not** get stripped): + +.. code-block:: python + + import m3u8 + + m3u = m3u8.load('list.m3u') + print(m3u.dumps()) + Using different HTTP clients ---------------------------- diff --git a/m3u8/model.py b/m3u8/model.py index 46911e48..cdd782e6 100644 --- a/m3u8/model.py +++ b/m3u8/model.py @@ -427,7 +427,8 @@ class Segment(BasePathMixin): def __init__(self, uri=None, base_uri=None, program_date_time=None, current_program_date_time=None, duration=None, title=None, byterange=None, cue_out=False, cue_out_start=False, cue_in=False, discontinuity=False, key=None, scte35=None, scte35_duration=None, - keyobject=None, parts=None, init_section=None, dateranges=None, gap_tag=None): + keyobject=None, parts=None, init_section=None, dateranges=None, gap_tag=None, + channel_number=None, extra_tags=None, icon_url=None, xmltv_id=None, language=None, tags=None): self.uri = uri self.duration = duration self.title = title @@ -441,6 +442,7 @@ def __init__(self, uri=None, base_uri=None, program_date_time=None, current_prog self.cue_in = cue_in self.scte35 = scte35 self.scte35_duration = scte35_duration + self.channel_number = channel_number self.key = keyobject self.parts = PartialSegmentList( [ PartialSegment(base_uri=self._base_uri, **partial) for partial in parts ] if parts else [] ) if init_section is not None: @@ -449,6 +451,11 @@ def __init__(self, uri=None, base_uri=None, program_date_time=None, current_prog self.init_section = None self.dateranges = DateRangeList( [ DateRange(**daterange) for daterange in dateranges ] if dateranges else [] ) self.gap_tag = gap_tag + self.extra_tags = extra_tags + self.icon_url = icon_url + self.xmltv_id = xmltv_id + self.language = language + self.tags = tags if tags is not None else [] # Key(base_uri=base_uri, **key) if key else None @@ -502,12 +509,31 @@ def dumps(self, last_segment): output.append('\n') if self.uri: + if self.extra_tags: + for tag in self.extra_tags: + output.append(tag) + output.append('\n') + if self.duration is not None: output.append('#EXTINF:%s,' % number_to_string(self.duration)) + if self.channel_number: + output.append('%s - ' % number_to_string(self.channel_number)) if self.title: output.append(self.title) output.append('\n') + if self.icon_url is not None or self.xmltv_id is not None or self.language is not None or len(self.tags) > 0: + #EXTTV:tag[,tag,tag...];language;XMLTV id[;icon URL] + + output.append('#EXTTV:%s;%s;%s' % ( + ",".join(self.tags), + self.language if self.language is not None else "", + self.xmltv_id if self.xmltv_id is not None else "" + )) + if self.icon_url is not None: + output.append(';%s' % self.icon_url) + output.append('\n') + if self.byterange: output.append('#EXT-X-BYTERANGE:%s\n' % self.byterange) diff --git a/m3u8/parser.py b/m3u8/parser.py index d7b51d33..397bb7e1 100644 --- a/m3u8/parser.py +++ b/m3u8/parser.py @@ -190,10 +190,15 @@ def parse(content, strict=False, custom_tags_parser=None): elif line.startswith(protocol.ext_x_gap): state['gap'] = True + elif line.startswith(protocol.extv): + _parse_exttv(line, data, state, lineno, strict) + # Comments and whitespace elif line.startswith('#'): if callable(custom_tags_parser): custom_tags_parser(line, data, lineno) + elif line.startswith('#EXT') and not line.startswith('#EXTM3U'): + _parse_extra_tag(line, data, state) elif line.strip() == '': # blank lines are legal @@ -226,6 +231,9 @@ def _parse_key(line): return key +CHANNEL_NUMBER = re.compile(r'(?is)^\s*\d+\s+-\s+') + +#EXTINF:duration,[channel number - ]channel name def _parse_extinf(line, data, state, lineno, strict): chunks = line.replace(protocol.extinf + ':', '').split(',', 1) if len(chunks) == 2: @@ -238,9 +246,46 @@ def _parse_extinf(line, data, state, lineno, strict): title = '' if 'segment' not in state: state['segment'] = {} + state['segment']['duration'] = float(duration) - state['segment']['title'] = title + channel_number = CHANNEL_NUMBER.match(title) + if channel_number is not None: + title = CHANNEL_NUMBER.sub('', title) + channel_number = re.split(r'\s+-\s+', channel_number.group(0))[0] + channel_number = channel_number.strip() + channel_number = int(channel_number) + state['segment']['channel_number'] = channel_number + state['segment']['title'] = title + else: + state['segment']['title'] = title + +#EXTTV:tag[,tag,tag...];language;XMLTV id[;icon URL] +def _parse_exttv(line, data, state, lineno, strict): + chunks = line.replace(protocol.extv + ':', '').split(';') + if len(chunks) < 3 and strict: + raise ParseError(lineno, line) + + if 'segment' not in state: + state['segment'] = {} + segment = state['segment'] + if len(chunks) >= 4: segment['icon_url'] = chunks[3] + if len(chunks) >= 3: segment['xmltv_id'] = chunks[2] + if len(chunks) >= 2: segment['language'] = chunks[1] + if len(chunks) >= 1: segment['tags'] = chunks[0].split(',') + + + + + + +def _parse_extra_tag(line, data, state): + if 'segment' not in state: + state['segment'] = {} + segment = state['segment'] + if 'extra_tags' not in segment: + segment['extra_tags'] = [] + segment['extra_tags'].append(line) def _parse_ts_chunk(line, data, state): segment = state.pop('segment') diff --git a/m3u8/protocol.py b/m3u8/protocol.py index 3db8b281..e2160657 100644 --- a/m3u8/protocol.py +++ b/m3u8/protocol.py @@ -37,3 +37,4 @@ ext_x_preload_hint = '#EXT-X-PRELOAD-HINT' ext_x_daterange = "#EXT-X-DATERANGE" ext_x_gap = "#EXT-X-GAP" +extv = "#EXTTV" diff --git a/tests/playlists.py b/tests/playlists.py index ae908c1a..e0295b4c 100755 --- a/tests/playlists.py +++ b/tests/playlists.py @@ -1057,6 +1057,43 @@ #EXT-X-PART:DURATION=1,URI="filePart271.c.ts" ''' +CUSTOM_TAGS_PLAYLIST = ''' +#EXTM3U +#EXT-CUSTOM-DATA-1: Hello +#EXT-CUSTOM-DATA-2: Dolly +#EXTINF:0,1 - IP TV 1 +udp://@232.1.1.1:9999 +#EXT-CUSTOM-DATA-1: A beautiful +#EXT-CUSTOM-DATA-2: world +#EXTINF:0,2 - IP TV 2 +udp://@232.1.1.2:9999 +''' + +EXTTV_PLAYLIST = ''' +#EXTM3U +#EXTINF:0, 114 - HBO (HD) * +#EXTTV:Fibre,HBO;eng;HBOAdriaHD.svn;HBO_HD.png +udp://@232.2.105.5:5002 +#EXTINF:0, 115 - ESP Int'l * +#EXTTV:Fibre,Sports;eng;Eurosport1.svn;Eurosport.png +udp://@232.2.2.25:5002 +#EXTINF:0, 116 - HBO Comedy (HD) * +#EXTTV:Fibre,HBO;eng +udp://@232.2.105.6:5002 +#EXTINF:0, 117 - ESP2 NE Intl * +#EXTTV:Fibre,Sports;;Eurosport2.svn;Eurosport_2_NE.png +udp://@232.2.2.26:5002 +#EXTINF:0, 118 - Cinemax (HD) * +#EXTTV:Movies;eng;Cinemax1.svn;Cinemax.png +udp://@232.2.105.7:5002 +''' + +EXTTV_INVALID_PLAYLIST = ''' +#EXTM3U +#EXTINF:0, 114 - HBO (HD) * +#EXTTV:Fibre,HBO +udp://@232.2.105.5:5002 + PLAYLIST_WITH_SLASH_IN_QUERY_STRING = ''' #EXTM3U #EXT-X-VERSION:3 diff --git a/tests/test_parser.py b/tests/test_parser.py index acd66325..758ddb57 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -492,6 +492,24 @@ def test_gap_in_parts(): assert data['segments'][0]['parts'][2]['gap_tag'] == True assert data['segments'][0]['parts'][2].get('gap', None) is None +def test_custom_tags_playlist(): + m3u = m3u8.M3U8(playlists.CUSTOM_TAGS_PLAYLIST) + assert playlists.CUSTOM_TAGS_PLAYLIST.strip() == m3u.dumps().strip() + +def test_exttv_playlist(): + data = m3u8.parse(playlists.EXTTV_PLAYLIST) + + assert data['segments'][0]['channel_number'] == 114 + assert data['segments'][0]['tags'] == ['Fibre', 'HBO'] + assert data['segments'][0]['language'] == 'eng' + assert data['segments'][0]['xmltv_id'] == 'HBOAdriaHD.svn' + assert data['segments'][0]['icon_url'] == 'HBO_HD.png' + +def test_exttv_invalid_playlist(): + with pytest.raises(ParseError) as catch: + m3u8.parse(playlists.EXTTV_INVALID_PLAYLIST, strict=True) + assert str(catch.value) == 'Syntax error in manifest on line 3: #EXTTV:Fibre,HBO' + def test_should_parse_variant_playlist_with_iframe_with_average_bandwidth(): data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_IFRAME_AVERAGE_BANDWIDTH) iframe_playlists = list(data['iframe_playlists']) @@ -515,3 +533,4 @@ def test_should_parse_variant_playlist_with_iframe_with_average_bandwidth(): assert 155000 == iframe_playlists[1]['iframe_stream_info']['average_bandwidth'] assert 65000 == iframe_playlists[2]['iframe_stream_info']['average_bandwidth'] assert 30000 == iframe_playlists[3]['iframe_stream_info']['average_bandwidth'] +