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.

Discussion:
@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"]]