Repository: globocom/m3u8

Issue:
MediaConvert EXT-CUE-OUT-CONT still problematic
Hi,
I still have problems with some HLS manifests generated by AWS MediaConvert, even after the fix to #349.
Here is an example: https://bpkio-cs-demos.s3-eu-west-1.amazonaws.com/fabre/mediaconvert-conditioning/output/hls/cts-laworder720p.m3u8

The lib fails on this line:
`#EXT-X-CUE-OUT-CONT:10/4, SpliceType=VOD_DAI,Action=REPLACE,PAID=amazon.com/TEST2014020500000346,Acds=BA`.

Note that this type of markup is coming straight from the (poor) AWS documentation at https://docs.aws.amazon.com/mediaconvert/latest/ug/example-esam-xml-manifest-conditioning.html

Hints from issue comments:
@bbayles: I'll take a shot at fixing this!

Solution patch:
diff --git a/m3u8/parser.py b/m3u8/parser.py
index cb16fe3..e2df4e2 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:
@@ -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..c2d249a 100755
--- a/tests/playlists.py
+++ b/tests/playlists.py
@@ -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():