Repository: globocom/m3u8

Issue:
Short segment duration is formatted in scientific notation which violates the spec for EXTINF
Short segments are re-formatted in scientific notation(`e-05`) which violates the [decimal floating point spec](https://tools.ietf.org/html/rfc8216#section-4.3.2.1).

Using the attached file perform the following

```
>>> p = m3u8.load("index-source-test.m3u8.txt")
>>> o = p.dumps()
>>> f = open('/tmp/index-source-lib.m3u8', 'w')
>>> f.write(o)
```

```
cat /tmp/index-source-lib.m3u8

#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:4
#EXT-X-PLAYLIST-TYPE:VOD
#EXTINF:1.1e-05,
segment0.ts?flagset=source
#EXT-X-ENDLIST
```

You can see that `#EXTINF:0.000011` got reformatted into `#EXTINF:1.1e-05`

Discussion:

Patch:
diff --git a/m3u8/model.py b/m3u8/model.py
index f9e90bf..01d9428 100644
--- a/m3u8/model.py
+++ b/m3u8/model.py
@@ -2,7 +2,7 @@
 # Copyright 2014 Globo.com Player authors. All rights reserved.
 # Use of this source code is governed by a MIT License
 # license that can be found in the LICENSE file.
-
+import decimal
 import os
 import errno
 import math
@@ -292,14 +292,14 @@ class M3U8(object):
             output.append('#EXT-X-MEDIA-SEQUENCE:' + str(self.media_sequence))
         if self.discontinuity_sequence:
             output.append('#EXT-X-DISCONTINUITY-SEQUENCE:{}'.format(
-                int_or_float_to_string(self.discontinuity_sequence)))
+                number_to_string(self.discontinuity_sequence)))
         if self.allow_cache:
             output.append('#EXT-X-ALLOW-CACHE:' + self.allow_cache.upper())
         if self.version:
             output.append('#EXT-X-VERSION:' + str(self.version))
         if self.target_duration:
             output.append('#EXT-X-TARGETDURATION:' +
-                          int_or_float_to_string(self.target_duration))
+                          number_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.start:
@@ -503,7 +503,7 @@ class Segment(BasePathMixin):
 
         if self.uri:
             if self.duration is not None:
-                output.append('#EXTINF:%s,' % int_or_float_to_string(self.duration))
+                output.append('#EXTINF:%s,' % number_to_string(self.duration))
                 if self.title:
                     output.append(self.title)
                 output.append('\n')
@@ -627,7 +627,7 @@ class PartialSegment(BasePathMixin):
             output.append('#EXT-X-GAP\n')
 
         output.append('#EXT-X-PART:DURATION=%s,URI="%s"' % (
-            int_or_float_to_string(self.duration), self.uri
+            number_to_string(self.duration), self.uri
         ))
 
         if self.independent:
@@ -1039,9 +1039,9 @@ class RenditionReport(BasePathMixin):
     def dumps(self):
         report = []
         report.append('URI=' + quoted(self.uri))
-        report.append('LAST-MSN=' + int_or_float_to_string(self.last_msn))
+        report.append('LAST-MSN=' + number_to_string(self.last_msn))
         if self.last_part is not None:
-            report.append('LAST-PART=' + int_or_float_to_string(
+            report.append('LAST-PART=' + number_to_string(
                 self.last_part))
 
         return ('#EXT-X-RENDITION-REPORT:' + ','.join(report))
@@ -1074,7 +1074,7 @@ class ServerControl(object):
             if self[attr]:
                 ctrl.append('%s=%s' % (
                     denormalize_attribute(attr),
-                    int_or_float_to_string(self[attr])
+                    number_to_string(self[attr])
                 ))
 
         return '#EXT-X-SERVER-CONTROL:' + ','.join(ctrl)
@@ -1087,7 +1087,7 @@ class Skip(object):
         self.skipped_segments = skipped_segments
 
     def dumps(self):
-        return '#EXT-X-SKIP:SKIPPED-SEGMENTS=%s' % int_or_float_to_string(
+        return '#EXT-X-SKIP:SKIPPED-SEGMENTS=%s' % number_to_string(
             self.skipped_segments)
 
     def __str__(self):
@@ -1098,7 +1098,7 @@ class PartInformation(object):
         self.part_target = part_target
 
     def dumps(self):
-        return '#EXT-X-PART-INF:PART-TARGET=%s' % int_or_float_to_string(
+        return '#EXT-X-PART-INF:PART-TARGET=%s' % number_to_string(
             self.part_target)
 
     def __str__(self):
@@ -1124,7 +1124,7 @@ class PreloadHint(BasePathMixin):
             if self[attr] is not None:
                 hint.append('%s=%s' % (
                     denormalize_attribute(attr),
-                    int_or_float_to_string(self[attr])
+                    number_to_string(self[attr])
                 ))
 
         return ('#EXT-X-PRELOAD-HINT:' + ','.join(hint))
@@ -1187,9 +1187,9 @@ class DateRange(object):
         if (self.end_date):
             daterange.append('END-DATE=' + quoted(self.end_date))
         if (self.duration):
-            daterange.append('DURATION=' + int_or_float_to_string(self.duration))
+            daterange.append('DURATION=' + number_to_string(self.duration))
         if (self.planned_duration):
-            daterange.append('PLANNED-DURATION=' + int_or_float_to_string(self.planned_duration))
+            daterange.append('PLANNED-DURATION=' + number_to_string(self.planned_duration))
         if (self.scte35_cmd):
             daterange.append('SCTE35-CMD=' + self.scte35_cmd)
         if (self.scte35_out):
@@ -1231,5 +1231,8 @@ def quoted(string):
     return '"%s"' % string
 
 
-def int_or_float_to_string(number):
-    return str(int(number)) if number == math.floor(number) else str(number)
+def number_to_string(number):
+    with decimal.localcontext() as ctx:
+        ctx.prec = 20  # set floating point precision
+        d = decimal.Decimal(str(number))
+        return str(d.quantize(decimal.Decimal(1)) if d == d.to_integral_value() else d.normalize())


Test patch:
diff --git a/tests/playlists.py b/tests/playlists.py
index 38b6f38..edcce8b 100755
--- a/tests/playlists.py
+++ b/tests/playlists.py
@@ -25,6 +25,18 @@ http://media.example.com/entire2.ts
 #EXT-X-ENDLIST
 '''
 
+SIMPLE_PLAYLIST_WITH_VERY_SHORT_DURATION = '''
+#EXTM3U
+#EXT-X-TARGETDURATION:5220
+#EXTINF:5220,
+http://media.example.com/entire1.ts
+#EXTINF:5218.5,
+http://media.example.com/entire2.ts
+#EXTINF:0.000011,
+http://media.example.com/entire3.ts
+#EXT-X-ENDLIST
+'''
+
 SIMPLE_PLAYLIST_WITH_START_NEGATIVE_OFFSET = '''
 #EXTM3U
 #EXT-X-TARGETDURATION:5220
diff --git a/tests/test_model.py b/tests/test_model.py
index 3b6f2dd..323a418 100755
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -645,6 +645,14 @@ def test_dump_should_not_ignore_zero_duration():
     assert "EXTINF:5220" in obj.dumps().strip()
 
 
+def test_dump_should_use_decimal_floating_point_for_very_short_durations():
+    obj = m3u8.M3U8(playlists.SIMPLE_PLAYLIST_WITH_VERY_SHORT_DURATION)
+
+    assert "EXTINF:5220" in obj.dumps().strip()
+    assert "EXTINF:5218.5" in obj.dumps().strip()
+    assert "EXTINF:0.000011" in obj.dumps().strip()
+
+
 def test_dump_should_include_segment_level_program_date_time():
     obj = m3u8.M3U8(playlists.DISCONTINUITY_PLAYLIST_WITH_PROGRAM_DATE_TIME)
     # Tag being expected is in the segment level, not the global one