Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for EXTTV and keeping of custom tags #209

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Supported tags
* `#EXT-X-SESSION-DATA`_
* `#EXT-X-DATERANGE`_
* `#EXT-X-GAP`_
* `#EXTTV`
* extra tags

Encryption keys
---------------
Expand Down Expand Up @@ -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
----------------------------

Expand Down
28 changes: 27 additions & 1 deletion m3u8/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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

Expand Down Expand Up @@ -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)

Expand Down
47 changes: 46 additions & 1 deletion m3u8/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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')
Expand Down
1 change: 1 addition & 0 deletions m3u8/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
37 changes: 37 additions & 0 deletions tests/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])
Expand All @@ -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']