Repository: globocom/m3u8

Issue:
'target_duration' returns float number, lead to iOS device fail to play the video
The `target_duration` attribute in the `m3u8` library returns a float value, i.e.: 4.0, if I write a new playlist with tag `#EXT-X-TARGETDURATION: 4.0`, it will cause iOS devices fail to play the video. 

I discovered this bug by many attempts. Only by changing the `target_duration` to an integer resolves this issue, could allow iOS devices to play the video correctly.


I hope this m3u8 library to be modified to ensure the target_duration is always returned as an integer. This change will align the library with the HLS specification and ensure compatibility with iOS devices.

Thank you for your attention.

Hints from issue comments:
@mauricioabreu: Hello @Scott0902 could you send a playlist example so we can reproduce this behavior?@bbayles: `EXT-X-TARGETDURATION` is indeed supposed to be an integer:
![image](https://github.com/user-attachments/assets/a15e866a-1db8-4f10-8635-99f92d379d0a)

The code currently calls [number_to_string](https://github.com/globocom/m3u8/blob/master/m3u8/model.py#L1650-L1658), which uses `to_integral_value` without a rounding specified. We could probably change it to use `ROUND_UP` for this particular value.
@mauricioabreu: @bbayles agreed! But I still wanna understand how `target_duration` was converted to `4.0`.

Here is the specific part where `target_duration` is converted to a number.

```python
if self.target_duration:
    output.append(
        "#EXT-X-TARGETDURATION:" + number_to_string(self.target_duration)
    )
```

```sh
>>> import m3u8
>>> m3u8.model.number_to_string("4")
'4'
>>> m3u8.model.number_to_string("4.0")
'4'
>>> m3u8.model.number_to_string("3.9")
'3.9'
```
@Scott0902: > Hello @Scott0902 could you send a playlist example so we can reproduce this behavior?

You can convert any H264-encoded `.mp4` video to `.ts` losslessly by using `ffmpeg.exe`, for example:

```
ffmpeg -i input.mp4 -c copy -bsf:v h264_mp4toannexb -f hls -hls_time 2 -hls_list_size 0 -hls_flags single_file -hls_segment_filename video.ts playlist.m3u8
```
By this way, `ffmpeg` will generate `playlist.m3u8` which is something like:

```
#EXTM3U
#EXT-X-VERSION:4
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:5.005000,
#EXT-X-BYTERANGE:311140@0
video.ts
#EXTINF:5.005000,
#EXT-X-BYTERANGE:679244@311140
video.ts
#EXTINF:5.005000,
#EXT-X-BYTERANGE:525272@990384
video.ts
#EXTINF:10.427089,
#EXT-X-BYTERANGE:831712@1515656
...
#EXT-X-ENDLIST
```

With an integer value of `#EXT-X-TARGETDURATION`, Safari of iPhone is able to play the video correctly, but NOT when the value is a float value.

Since `m3u8.target_duration` would return a float value, if I use this library to concatenate some different mp4 videos and output a new playlist, Safari of iPhone would fail to play it.@mauricioabreu: @Scott0902 now I get it. Thank you! I'm going to fix it ASAP.@mauricioabreu: Could you review this pull request? https://github.com/globocom/m3u8/pull/372
@bbayles @Scott0902 @mauricioabreu: @Scott0902 it seems solved. If you think it is not, reopen the issue. Thank you!

Solution patch:
diff --git a/m3u8/parser.py b/m3u8/parser.py
index cb16fe3..015adfd 100644
--- a/m3u8/parser.py
+++ b/m3u8/parser.py
@@ -320,9 +320,14 @@ def _parse_attribute_list(prefix, line, attribute_parser, default_parser=None):
 
     attributes = {}
     for param in params:
-        name, value = param.split("=", 1)
-        name = normalize_attribute(name)
+        param_parts = param.split("=", 1)
+        if len(param_parts) == 1:
+            name = ""
+            value = param_parts[0]
+        else:
+            name, value = param_parts
 
+        name = normalize_attribute(name)
         if name in attribute_parser:
             value = attribute_parser[name](value)
         elif default_parser is not None:
@@ -447,7 +452,7 @@ def _parse_byterange(line, state, **kwargs):
 
 
 def _parse_targetduration(**parse_kwargs):
-    return _parse_simple_parameter(cast_to=float, **parse_kwargs)
+    return _parse_simple_parameter(cast_to=int, **parse_kwargs)
 
 
 def _parse_media_sequence(**parse_kwargs):
@@ -548,16 +553,6 @@ def _parse_cueout_cont(line, state, **kwargs):
     if len(elements) != 2:
         return
 
-    # EXT-X-CUE-OUT-CONT:2.436/120 style
-    res = re.match(
-        r"^[-+]?([0-9]+(\.[0-9]+)?|\.[0-9]+)/[-+]?([0-9]+(\.[0-9]+)?|\.[0-9]+)$",
-        elements[1],
-    )
-    if res:
-        state["current_cue_out_elapsedtime"] = res.group(1)
-        state["current_cue_out_duration"] = res.group(3)
-        return
-
     # EXT-X-CUE-OUT-CONT:ElapsedTime=10,Duration=60,SCTE35=... style
     cue_info = _parse_attribute_list(
         protocol.ext_x_cue_out_cont,
@@ -565,6 +560,16 @@ def _parse_cueout_cont(line, state, **kwargs):
         remove_quotes_parser("duration", "elapsedtime", "scte35"),
     )
 
+    # EXT-X-CUE-OUT-CONT:2.436/120 style
+    progress = cue_info.get("")
+    if progress:
+        progress_parts = progress.split("/", 1)
+        if len(progress_parts) == 1:
+            state["current_cue_out_duration"] = progress_parts[0]
+        else:
+            state["current_cue_out_elapsedtime"] = progress_parts[0]
+            state["current_cue_out_duration"] = progress_parts[1]
+
     duration = cue_info.get("duration")
     if duration:
         state["current_cue_out_duration"] = duration
@@ -578,55 +583,28 @@ def _parse_cueout_cont(line, state, **kwargs):
         state["current_cue_out_elapsedtime"] = elapsedtime
 
 
-def _cueout_no_duration(line):
-    # this needs to be called first since line.split in all other
-    # parsers will throw a ValueError if passed just this tag
-    if line == protocol.ext_x_cue_out:
-        return (None, None)
-
-
-def _cueout_envivio(line, state):
-    param, value = line.split(":", 1)
-    res = re.match('.*DURATION=(.*),.*,CUE="(.*)"', value)
-    if res:
-        return (res.group(2), res.group(1))
-    else:
-        return None
-
-
-def _cueout_duration(line):
-    # This was added separately rather than modifying "simple"
-    param, value = line.split(":", 1)
-    res = re.match(r"DURATION=(.*)", value)
-    if res:
-        return (None, res.group(1))
-
-
-def _cueout_simple(line):
-    param, value = line.split(":", 1)
-    res = re.match(r"^(\d+(?:\.\d)?\d*)$", value)
-    if res:
-        return (None, res.group(1))
-
-
 def _parse_cueout(line, state, **kwargs):
-    _cueout_state = (
-        _cueout_no_duration(line)
-        or _cueout_envivio(line, state)
-        or _cueout_duration(line)
-        or _cueout_simple(line)
-    )
-    if _cueout_state:
-        cue_out_scte35, cue_out_duration = _cueout_state
-        current_cue_out_scte35 = state.get("current_cue_out_scte35")
-        state["current_cue_out_scte35"] = cue_out_scte35 or current_cue_out_scte35
-        state["current_cue_out_duration"] = cue_out_duration
-
     state["cue_out_start"] = True
     state["cue_out"] = True
     if "DURATION" in line.upper():
         state["cue_out_explicitly_duration"] = True
 
+    elements = line.split(":", 1)
+    if len(elements) != 2:
+        return
+
+    cue_info = _parse_attribute_list(
+        protocol.ext_x_cue_out,
+        line,
+        remove_quotes_parser("cue"),
+    )
+    cue_out_scte35 = cue_info.get("cue")
+    cue_out_duration = cue_info.get("duration") or cue_info.get("")
+
+    current_cue_out_scte35 = state.get("current_cue_out_scte35")
+    state["current_cue_out_scte35"] = cue_out_scte35 or current_cue_out_scte35
+    state["current_cue_out_duration"] = cue_out_duration
+
 
 def _parse_server_control(line, data, **kwargs):
     attribute_parser = {


Test patch:
diff --git a/tests/playlists.py b/tests/playlists.py
index 4a5e497..2e3063d 100755
--- a/tests/playlists.py
+++ b/tests/playlists.py
@@ -65,7 +65,7 @@ REDIRECT_PLAYLIST_URI = TEST_HOST + "/path/to/redirect_me"
 
 PLAYLIST_WITH_NON_INTEGER_DURATION = """
 #EXTM3U
-#EXT-X-TARGETDURATION:5220.5
+#EXT-X-TARGETDURATION:5221
 #EXTINF:5220.5,
 http://media.example.com/entire.ts
 """
@@ -781,6 +781,31 @@ segment_19980228.ts
 segment_19980229.ts
 """
 
+CUE_OUT_MEDIACONVERT_PLAYLIST = """\
+#EXTM3U
+#EXT-X-VERSION:3
+#EXT-X-TARGETDURATION:11
+#EXT-X-MEDIA-SEQUENCE:1
+#EXT-X-PLAYLIST-TYPE:VOD
+#EXTINF:10,
+segment_00001.ts
+#EXT-X-CUE-OUT:4,SpliceType=VOD_DAI,Action=REPLACE, PAID=example.com/2024073010700,Acds=BA
+#EXTINF:10,
+segment_00002.ts
+#EXT-X-CUE-OUT-CONT:10/4, SpliceType=VOD_DAI,Action=REPLACE,PAID=example.com/2024073010700,Acds=BA
+#EXTINF:10,
+segment_00003.ts
+#EXTINF:10,
+segment_00004.ts
+#EXT-X-CUE-IN:4,SpliceType=VOD_DAI
+#EXTINF:0,
+segment_00005.ts
+#EXTINF:10,
+segment_00006.ts
+#EXT-X-ENDLIST
+"""
+
+
 CUE_OUT_ENVIVIO_PLAYLIST = """
 #EXTM3U
 #EXT-X-VERSION:3
diff --git a/tests/test_model.py b/tests/test_model.py
index 97d3bcd..dd66a4b 100755
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -257,6 +257,14 @@ def test_segment_cue_out_cont_alt():
     assert segments[3].scte35_duration == "120.0"
 
 
+def test_segment_cue_out_cont_mediaconvert():
+    obj = m3u8.M3U8(playlists.CUE_OUT_MEDIACONVERT_PLAYLIST)
+    segments = obj.segments
+
+    assert segments[2].scte35_elapsedtime == "10"
+    assert segments[2].scte35_duration == "4"
+
+
 def test_segment_envivio_scte35_attribute():
     obj = m3u8.M3U8(playlists.CUE_OUT_ENVIVIO_PLAYLIST)
     segments = obj.segments
@@ -273,7 +281,7 @@ def test_segment_envivio_scte35_attribute():
 def test_segment_unknown_scte35_attribute():
     obj = m3u8.M3U8(playlists.CUE_OUT_INVALID_PLAYLIST)
     assert obj.segments[0].scte35 is None
-    assert obj.segments[0].scte35_duration is None
+    assert obj.segments[0].scte35_duration == "INVALID"
 
 
 def test_segment_cue_out_no_duration():
diff --git a/tests/test_parser.py b/tests/test_parser.py
index a228c4a..a501571 100644
--- a/tests/test_parser.py
+++ b/tests/test_parser.py
@@ -28,7 +28,7 @@ def test_should_parse_simple_playlist_from_string():
 
 def test_should_parse_non_integer_duration_from_playlist_string():
     data = m3u8.parse(playlists.PLAYLIST_WITH_NON_INTEGER_DURATION)
-    assert 5220.5 == data["targetduration"]
+    assert 5221 == data["targetduration"]
     assert [5220.5] == [c["duration"] for c in data["segments"]]