Repository: globocom/m3u8

Issue:
Add support for EXT-X-SESSION-DATA tag
`The EXT-X-SESSION-DATA tag allows arbitrary session data to be
   carried in a Master Playlist.`
https://tools.ietf.org/html/rfc8216#section-4.3.4.4

Comments:

Patch:
diff --git a/README.rst b/README.rst
index 01f8788..fbab918 100644
--- a/README.rst
+++ b/README.rst
@@ -64,6 +64,7 @@ Supported tags
 * #EXT-X-PART
 * #EXT-X-RENDITION-REPORT
 * #EXT-X-SKIP
+* #EXT-X-SESSION-DATA
 
 Encryption keys
 ---------------
diff --git a/m3u8/model.py b/m3u8/model.py
index 05353b0..2792d0e 100644
--- a/m3u8/model.py
+++ b/m3u8/model.py
@@ -195,6 +195,10 @@ class M3U8(object):
         self.rendition_reports = RenditionReportList([ RenditionReport(base_uri=self.base_uri, **rendition_report)
                                                   for rendition_report in self.data.get('rendition_reports', []) ])
 
+        self.session_data = SessionDataList([ SessionData(**session_data)
+                             for session_data in self.data.get('session_data', [])
+                             if 'data_id' in session_data ])
+
     def __unicode__(self):
         return self.dumps()
 
@@ -295,6 +299,8 @@ class M3U8(object):
             output.append(str(self.part_inf))
         if self.skip:
             output.append(str(self.skip))
+        if self.session_data:
+            output.append(str(self.session_data))
 
         output.append(str(self.segments))
 
@@ -805,22 +811,26 @@ class Media(BasePathMixin):
         return self.dumps()
 
 
-class MediaList(list, GroupedBasePathMixin):
+class TagList(list):
 
     def __str__(self):
-        output = [str(playlist) for playlist in self]
+        output = [str(tag) for tag in self]
         return '\n'.join(output)
 
+
+class MediaList(TagList, GroupedBasePathMixin):
+
     @property
     def uri(self):
         return [media.uri for media in self]
 
 
-class PlaylistList(list, GroupedBasePathMixin):
+class PlaylistList(TagList, GroupedBasePathMixin):
+    pass
 
-    def __str__(self):
-        output = [str(playlist) for playlist in self]
-        return '\n'.join(output)
+
+class SessionDataList(TagList):
+    pass
 
 
 class Start(object):
@@ -913,6 +923,28 @@ class PartInformation(object):
     def __str__(self):
         return self.dumps()
 
+class SessionData(object):
+    def __init__(self, data_id, value=None, uri=None, language=None):
+        self.data_id = data_id
+        self.value = value
+        self.uri = uri
+        self.language = language
+
+    def dumps(self):
+        session_data_out = ['DATA-ID=' + quoted(self.data_id)]
+
+        if self.value:
+            session_data_out.append('VALUE=' + quoted(self.value))
+        elif self.uri:
+            session_data_out.append('URI=' + quoted(self.uri))
+        if self.language:
+            session_data_out.append('LANGUAGE=' + quoted(self.language))
+
+        return '#EXT-X-SESSION-DATA:' + ','.join(session_data_out)
+
+    def __str__(self):
+        return self.dumps()
+
 def find_key(keydata, keylist):
     if not keydata:
         return None
diff --git a/m3u8/parser.py b/m3u8/parser.py
index 2bc9534..a79f105 100644
--- a/m3u8/parser.py
+++ b/m3u8/parser.py
@@ -53,7 +53,8 @@ def parse(content, strict=False, custom_tags_parser=None):
         'keys': [],
         'rendition_reports': [],
         'skip': {},
-        'part_inf': {}
+        'part_inf': {},
+        'session_data': []
     }
 
     state = {
@@ -169,6 +170,9 @@ def parse(content, strict=False, custom_tags_parser=None):
         elif line.startswith(protocol.ext_x_skip):
             _parse_skip(line, data, state)
 
+        elif line.startswith(protocol.ext_x_session_data):
+            _parse_session_data(line, data, state)
+
         # Comments and whitespace
         elif line.startswith('#'):
             if callable(custom_tags_parser):
@@ -407,6 +411,11 @@ def _parse_skip(line, data, state):
 
     data['skip'] = _parse_attribute_list(protocol.ext_x_skip, line, attribute_parser)
 
+def _parse_session_data(line, data, state):
+    quoted = remove_quotes_parser('data_id', 'value', 'uri', 'language')
+    session_data = _parse_attribute_list(protocol.ext_x_session_data, line, quoted)
+    data['session_data'].append(session_data)
+
 def string_to_lines(string):
     return string.strip().splitlines()
 
diff --git a/m3u8/protocol.py b/m3u8/protocol.py
index bbb724e..3933e80 100644
--- a/m3u8/protocol.py
+++ b/m3u8/protocol.py
@@ -33,3 +33,4 @@ ext_x_part_inf = '#EXT-X-PART-INF'
 ext_x_part = '#EXT-X-PART'
 ext_x_rendition_report = '#EXT-X-RENDITION-REPORT'
 ext_x_skip = '#EXT-X-SKIP'
+ext_x_session_data = '#EXT-X-SESSION-DATA'


Test patch:
diff --git a/tests/playlists.py b/tests/playlists.py
index 26e9cd1..04324af 100755
--- a/tests/playlists.py
+++ b/tests/playlists.py
@@ -802,4 +802,17 @@ http://example.com/hi.m3u8
 http://example.com/audio-only.m3u8
 '''
 
+SESSION_DATA_PLAYLIST = '''#EXTM3U
+#EXT-X-VERSION:4
+#EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en"
+'''
+
+MULTIPLE_SESSION_DATA_PLAYLIST = '''#EXTM3U
+#EXT-X-VERSION:4
+#EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en"
+#EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="ru"
+#EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="de"
+#EXT-X-SESSION-DATA:DATA-ID="com.example.title",URI="title.json"
+'''
+
 del abspath, dirname, join
diff --git a/tests/test_model.py b/tests/test_model.py
index f6bf956..f1dea64 100755
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -15,7 +15,7 @@ from m3u8.protocol import ext_x_start, ext_x_part
 
 import m3u8
 import playlists
-from m3u8.model import Segment, Key, Media, MediaList, RenditionReport, PartialSegment, denormalize_attribute, find_key
+from m3u8.model import Segment, Key, Media, MediaList, RenditionReport, PartialSegment, denormalize_attribute, find_key, SessionData
 
 
 class UTC(datetime.tzinfo):
@@ -631,6 +631,18 @@ def test_should_dump_complex_unencrypted_encrypted_keys_no_uri_attr():
 
     assert expected == obj.dumps().strip()
 
+def test_should_dump_session_data():
+    obj = m3u8.M3U8(playlists.SESSION_DATA_PLAYLIST)
+    expected = playlists.SESSION_DATA_PLAYLIST.strip()
+
+    assert expected == obj.dumps().strip()
+
+def test_should_dump_multiple_session_data():
+    obj = m3u8.M3U8(playlists.MULTIPLE_SESSION_DATA_PLAYLIST)
+    expected = playlists.MULTIPLE_SESSION_DATA_PLAYLIST.strip()
+
+    assert expected == obj.dumps().strip()
+
 
 
 def test_length_segments_by_key():
@@ -1002,6 +1014,43 @@ def test_partial_segment_gap_and_byterange():
 
     assert result == expected
 
+def test_session_data_with_value():
+    obj = SessionData(
+        'com.example.value',
+        'example',
+        language='en'
+    )
+
+    result = obj.dumps()
+    expected = '#EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en"'
+
+    assert result == expected
+
+def test_session_data_with_uri():
+    obj = SessionData(
+        'com.example.value',
+        uri='example.json',
+        language='en'
+    )
+
+    result = obj.dumps()
+    expected = '#EXT-X-SESSION-DATA:DATA-ID="com.example.value",URI="example.json",LANGUAGE="en"'
+
+    assert result == expected
+
+def test_session_data_cannot_be_created_with_value_and_uri_at_the_same_time():
+    obj = SessionData(
+        'com.example.value',
+        value='example',
+        uri='example.json',
+        language='en'
+    )
+
+    result = obj.dumps()
+    expected = '#EXT-X-SESSION-DATA:DATA-ID="com.example.value",VALUE="example",LANGUAGE="en"'
+
+    assert result == expected
+
 def test_endswith_newline():
     obj = m3u8.loads(playlists.SIMPLE_PLAYLIST)
 
diff --git a/tests/test_parser.py b/tests/test_parser.py
index 71be2d8..912d408 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -317,6 +317,34 @@ def test_should_parse_start_with_precise():
     assert data['start']['time_offset'] == 10.5
     assert data['start']['precise'] == 'YES'
 
+
+def test_should_parse_session_data():
+    data = m3u8.parse(playlists.SESSION_DATA_PLAYLIST)
+    assert data['session_data'][0]['data_id'] == 'com.example.value'
+    assert data['session_data'][0]['value'] == 'example'
+    assert data['session_data'][0]['language'] == 'en'
+
+
+def test_should_parse_multiple_session_data():
+    data = m3u8.parse(playlists.MULTIPLE_SESSION_DATA_PLAYLIST)
+
+    assert len(data['session_data']) == 4
+
+    assert data['session_data'][0]['data_id'] == 'com.example.value'
+    assert data['session_data'][0]['value'] == 'example'
+    assert data['session_data'][0]['language'] == 'en'
+
+    assert data['session_data'][1]['data_id'] == 'com.example.value'
+    assert data['session_data'][1]['value'] == 'example'
+    assert data['session_data'][1]['language'] == 'ru'
+
+    assert data['session_data'][2]['data_id'] == 'com.example.value'
+    assert data['session_data'][2]['value'] == 'example'
+    assert data['session_data'][2]['language'] == 'de'
+
+    assert data['session_data'][3]['data_id'] == 'com.example.title'
+    assert data['session_data'][3]['uri'] == 'title.json'
+
 def test_simple_playlist_with_discontinuity_sequence():
     data = m3u8.parse(playlists.SIMPLE_PLAYLIST_WITH_DISCONTINUITY_SEQUENCE)
     assert data['discontinuity_sequence'] == 123