Repository: globocom/m3u8

Issue:
Add support for FRAME-RATE tag
Tag `FRAME-RATE` is ignored when parsing and dumping a master playlist.

For example when [master-with-stream-inf-1.m3u8](https://github.com/grafov/m3u8/blob/master/sample-playlists/master-with-stream-inf-1.m3u8), which contains `FRAME-RATE` tag, is loaded:
```
m3u8_obj = m3u8.load('master-with-stream-inf-1.m3u8')
```
And then dumped:
```
print(m3u8_obj.dumps())
```
The `FRAME-RATE` tag is gone:
```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=300000,AVERAGE-BANDWIDTH=300000,CODECS="avc1.42c015,mp4a.40.2"
chunklist-b300000.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=600000,AVERAGE-BANDWIDTH=600000,CODECS="avc1.42c015,mp4a.40.2"
chunklist-b600000.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=850000,AVERAGE-BANDWIDTH=850000,CODECS="avc1.42c015,mp4a.40.2"
chunklist-b850000.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1000000,AVERAGE-BANDWIDTH=1000000,CODECS="avc1.42c015,mp4a.40.2"
chunklist-b1000000.m3u8
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1500000,AVERAGE-BANDWIDTH=1500000,CODECS="avc1.42c015,mp4a.40.2"
chunklist-b1500000.m3u8
```

The solution would be probably to extend `stream_info` for `Playlist` class.

Discussion:
coveralls: 
[![Coverage Status](https://coveralls.io/builds/24894261/badge)](https://coveralls.io/builds/24894261)

Coverage increased (+0.01%) to 97.313% when pulling **edc2da9885525d74fc7541ffb283e00bf3a56b3f on gorzynski:frame-rate-support** into **a84b2ac0bd7aec5acc5cf06a6db9746851863915 on globocom:master**.


Patch:
diff --git a/m3u8/model.py b/m3u8/model.py
index 1e12148..fce4fbf 100644
--- a/m3u8/model.py
+++ b/m3u8/model.py
@@ -510,7 +510,8 @@ class Playlist(BasePathMixin):
             average_bandwidth=stream_info.get('average_bandwidth'),
             program_id=stream_info.get('program_id'),
             resolution=resolution_pair,
-            codecs=stream_info.get('codecs')
+            codecs=stream_info.get('codecs'),
+            frame_rate=stream_info.get('frame_rate')
         )
         self.media = []
         for media_type in ('audio', 'video', 'subtitles'):
@@ -535,6 +536,8 @@ class Playlist(BasePathMixin):
             res = str(self.stream_info.resolution[
                       0]) + 'x' + str(self.stream_info.resolution[1])
             stream_inf.append('RESOLUTION=' + res)
+        if self.stream_info.frame_rate:
+            stream_inf.append('FRAME-RATE=%.5g' % self.stream_info.frame_rate)
         if self.stream_info.codecs:
             stream_inf.append('CODECS=' + quoted(self.stream_info.codecs))
 
@@ -586,7 +589,8 @@ class IFramePlaylist(BasePathMixin):
             average_bandwidth=None,
             program_id=iframe_stream_info.get('program_id'),
             resolution=resolution_pair,
-            codecs=iframe_stream_info.get('codecs')
+            codecs=iframe_stream_info.get('codecs'),
+            frame_rate=None
         )
 
     def __str__(self):
@@ -611,7 +615,7 @@ class IFramePlaylist(BasePathMixin):
 
 StreamInfo = namedtuple(
     'StreamInfo',
-    ['bandwidth', 'closed_captions', 'average_bandwidth', 'program_id', 'resolution', 'codecs', 'audio', 'video', 'subtitles']
+    ['bandwidth', 'closed_captions', 'average_bandwidth', 'program_id', 'resolution', 'codecs', 'audio', 'video', 'subtitles', 'frame_rate']
 )
 
 
diff --git a/m3u8/parser.py b/m3u8/parser.py
index ccd5696..64fbdb8 100644
--- a/m3u8/parser.py
+++ b/m3u8/parser.py
@@ -243,6 +243,7 @@ def _parse_stream_inf(line, data, state):
     atribute_parser["program_id"] = int
     atribute_parser["bandwidth"] = lambda x: int(float(x))
     atribute_parser["average_bandwidth"] = int
+    atribute_parser["frame_rate"] = float
     state['stream_info'] = _parse_attribute_list(protocol.ext_x_stream_inf, line, atribute_parser)
 
 


Test patch:
diff --git a/tests/playlists.py b/tests/playlists.py
index c912a04..44d635e 100755
--- a/tests/playlists.py
+++ b/tests/playlists.py
@@ -707,4 +707,16 @@ CUE_OUT_PLAYLIST_FILENAME = abspath(join(dirname(__file__), 'playlists/cue_out.m
 
 CUE_OUT_PLAYLIST_URI = TEST_HOST + '/path/to/cue_out.m3u8'
 
+VARIANT_PLAYLIST_WITH_FRAME_RATE = '''
+#EXTM3U
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=1280000,FRAME-RATE=25
+http://example.com/low.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=2560000,FRAME-RATE=50
+http://example.com/mid.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=7680000,FRAME-RATE=60
+http://example.com/hi.m3u8
+#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=65000,FRAME-RATE=12.5,CODECS="mp4a.40.5,avc1.42801e"
+http://example.com/audio-only.m3u8
+'''
+
 del abspath, dirname, join
diff --git a/tests/test_model.py b/tests/test_model.py
index 3225d93..b8bb70d 100755
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -818,6 +818,13 @@ def test_playlist_stream_info_contains_group_id_refs():
         assert pl.stream_info.audio == 'aud'
         assert pl.stream_info.video == 'vid'
 
+def test_should_dump_frame_rate():
+    obj = m3u8.M3U8(playlists.VARIANT_PLAYLIST_WITH_FRAME_RATE)
+    expected = playlists.VARIANT_PLAYLIST_WITH_FRAME_RATE.strip()
+
+    assert expected == obj.dumps().strip()
+
+
 
 # custom asserts
 
diff --git a/tests/test_parser.py b/tests/test_parser.py
index e8b08ef..6f2400e 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -325,4 +325,12 @@ def test_simple_playlist_with_custom_tags():
     assert 5220 == data['targetduration']
     assert 0 == data['media_sequence']
     assert ['http://media.example.com/entire.ts'] == [c['uri'] for c in data['segments']]
-    assert [5220] == [c['duration'] for c in data['segments']]
\ No newline at end of file
+    assert [5220] == [c['duration'] for c in data['segments']]
+
+def test_master_playlist_with_frame_rate():
+    data = m3u8.parse(playlists.VARIANT_PLAYLIST_WITH_FRAME_RATE)
+    playlists_list = list(data['playlists'])
+    assert 25 == playlists_list[0]['stream_info']['frame_rate']
+    assert 50 == playlists_list[1]['stream_info']['frame_rate']
+    assert 60 == playlists_list[2]['stream_info']['frame_rate']
+    assert 12.5 == playlists_list[3]['stream_info']['frame_rate']