Repository: streamlink/streamlink

Issue:
plugins.gulli: No playable streams found on this URL: https://replay.gulli.fr/Direct
### Checklist

- [X] This is a [plugin issue](https://streamlink.github.io/plugins.html) and not [a different kind of issue](https://github.com/streamlink/streamlink/issues/new/choose)
- [X] [I have read the contribution guidelines](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink)
- [X] [I have checked the list of open and recently closed plugin issues](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22)
- [X] [I have checked the commit log of the master branch](https://github.com/streamlink/streamlink/commits/master)

### Streamlink version

streamlink 6.4.2

### Description

The gulli plugin can't find the stream on the gulli page: https://replay.gulli.fr/Direct

Opening the page from a browser will auto-play the video.

### Debug log

```text
/ # streamlink --loglevel debug  https://replay.gulli.fr/Direct
[cli][info] streamlink is running as root! Be careful!
[cli][debug] OS:         Linux-6.1.21-v8+-aarch64-with
[cli][debug] Python:     3.9.13
[cli][debug] OpenSSL:    OpenSSL 1.1.1o  3 May 2022
[cli][debug] Streamlink: 6.4.2
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.11.17
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.3
[cli][debug]  pycountry: 23.12.11
[cli][debug]  pycryptodome: 3.19.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.23.2
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  typing-extensions: 4.9.0
[cli][debug]  urllib3: 2.1.0
[cli][debug]  websocket-client: 1.7.0
[cli][debug] Arguments:
[cli][debug]  url=https://replay.gulli.fr/Direct
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin gulli for URL https://replay.gulli.fr/Direct
error: No playable streams found on this URL: https://replay.gulli.fr/Direct
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/gulli.py b/src/streamlink/plugins/gulli.py
--- a/src/streamlink/plugins/gulli.py
+++ b/src/streamlink/plugins/gulli.py
@@ -11,79 +11,37 @@
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.hls import HLSStream
-from streamlink.stream.http import HTTPStream
 
 
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(
-    r"https?://replay\.gulli\.fr/(?:Direct|.+/(?P<video_id>VOD\d+))",
-))
+@pluginmatcher(
+    name="live",
+    pattern=re.compile(r"https?://replay\.gulli\.fr/Direct"),
+)
+@pluginmatcher(
+    name="vod",
+    pattern=re.compile(r"https?://replay\.gulli\.fr/.+-(?P<video_id>(?i:vod)\d+)$"),
+)
 class Gulli(Plugin):
     LIVE_PLAYER_URL = "https://replay.gulli.fr/jwplayer/embedstreamtv"
     VOD_PLAYER_URL = "https://replay.gulli.fr/jwplayer/embed/{0}"
 
-    _playlist_re = re.compile(r"sources: (\[.+?\])", re.DOTALL)
-    _vod_video_index_re = re.compile(r"jwplayer\(idplayer\).playlistItem\((?P<video_index>[0-9]+)\)")
-    _mp4_bitrate_re = re.compile(r".*_(?P<bitrate>[0-9]+)\.mp4")
-
-    _video_schema = validate.Schema(
-        validate.all(
-            validate.transform(lambda x: re.sub(r'"?file"?:\s*[\'"](.+?)[\'"],?', r'"file": "\1"', x, flags=re.DOTALL)),
-            validate.transform(lambda x: re.sub(r'"?\w+?"?:\s*function\b.*?(?<={).*(?=})', "", x, flags=re.DOTALL)),
-            validate.parse_json(),
-            [
-                validate.Schema({
-                    "file": validate.url(),
-                }),
-            ],
-        ),
-    )
-
     def _get_streams(self):
-        video_id = self.match.group("video_id")
-        if video_id is not None:
-            # VOD
-            live = False
-            player_url = self.VOD_PLAYER_URL.format(video_id)
-        else:
-            # Live
-            live = True
+        if self.matches["live"]:
             player_url = self.LIVE_PLAYER_URL
+        else:
+            player_url = self.VOD_PLAYER_URL.format(self.match["video_id"])
 
-        res = self.session.http.get(player_url)
-        playlist = re.findall(self._playlist_re, res.text)
-        index = 0
-        if not live:
-            # Get the index for the video on the playlist
-            match = self._vod_video_index_re.search(res.text)
-            if match is None:
-                return
-            index = int(match.group("video_index"))
-
-        if not playlist:
+        video_url = self.session.http.get(player_url, schema=validate.Schema(
+            validate.parse_html(),
+            validate.xml_xpath_string(".//video-js[1]/source[@src][@type='application/x-mpegURL'][1]/@src"),
+        ))
+        if not video_url:
             return
-        videos = self._video_schema.validate(playlist[index])
-
-        for video in videos:
-            video_url = video["file"]
-
-            # Ignore non-supported MSS streams
-            if "isml/Manifest" in video_url:
-                continue
 
-            try:
-                if ".m3u8" in video_url:
-                    yield from HLSStream.parse_variant_playlist(self.session, video_url).items()
-                elif ".mp4" in video_url:
-                    match = self._mp4_bitrate_re.match(video_url)
-                    bitrate = "vod" if match is None else f"{match.group('bitrate')}k"
-                    yield bitrate, HTTPStream(self.session, video_url)
-            except OSError as err:
-                if "403 Client Error" in str(err):
-                    log.error("Failed to access stream, may be due to geo-restriction")
-                raise
+        return HLSStream.parse_variant_playlist(self.session, video_url)
 
 
 __plugin__ = Gulli


Test patch:
diff --git a/tests/plugins/test_gulli.py b/tests/plugins/test_gulli.py
--- a/tests/plugins/test_gulli.py
+++ b/tests/plugins/test_gulli.py
@@ -6,10 +6,22 @@ class TestPluginCanHandleUrlGulli(PluginCanHandleUrl):
     __plugin__ = Gulli
 
     should_match = [
-        "http://replay.gulli.fr/Direct",
-        "http://replay.gulli.fr/dessins-animes/My-Little-Pony-les-amies-c-est-magique/VOD68328764799000",
-        "https://replay.gulli.fr/emissions/In-Ze-Boite2/VOD68639028668000",
-        "https://replay.gulli.fr/series/Power-Rangers-Dino-Super-Charge/VOD68612908435000",
+        (
+            "live",
+            "https://replay.gulli.fr/Direct",
+        ),
+        (
+            "vod",
+            "https://replay.gulli.fr/dessins-animes/Bob-l-eponge-s10/bob-l-eponge-s10-e05-un-bon-gros-dodo-vod69736581475000",
+        ),
+        (
+            "vod",
+            "https://replay.gulli.fr/emissions/Animaux-VIP-une-bete-de-reno-s02/animaux-vip-une-bete-de-reno-s02-e09-la-taniere-du-dragon-vod69634261609000",
+        ),
+        (
+            "vod",
+            "https://replay.gulli.fr/series/Black-Panther-Dangers-au-Wakanda/black-panther-dangers-au-wakanda-black-panther-dangers-au-wakanda-vod69941412154000",
+        ),
     ]
 
     should_not_match = [