Repository: streamlink/streamlink

Issue:
plugins.pluzz: live or replay from francetv doesn't work anymore
### 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)

### Collaboration

- [X] [I will provide feedback should a pull request be opened with a fix for the plugin](https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#pull-request-feedback)

### Streamlink version

[cli][info] Your Streamlink version (6.8.1) is up to date!

### Description

I've noticed that this plugin hasn't been working for a week now. Neither on live broadcasts nor on replays.

### Debug log

```text
[session][debug] Loading plugin: pluzz
[cli][debug] OS:         Linux-6.8.0-35-generic-x86_64-with-glibc2.39
[cli][debug] Python:     3.12.3
[cli][debug] OpenSSL:    OpenSSL 3.0.13 30 Jan 2024
[cli][debug] Streamlink: 6.8.1
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.6.2
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 5.2.2
[cli][debug]  pycountry: 24.6.1
[cli][debug]  pycryptodome: 3.20.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.32.3
[cli][debug]  trio: 0.25.1
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  typing-extensions: 4.12.2
[cli][debug]  urllib3: 2.2.2
[cli][debug]  websocket-client: 1.8.0
[cli][debug] Arguments:
[cli][debug]  url=https://www.france.tv/france-2/planete-terre/5875566-le-prix-de-la-survie.html
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=mpv
[cli][info] Found matching plugin pluzz for URL https://www.france.tv/france-2/planete-terre/5875566-le-prix-de-la-survie.html
[plugins.pluzz][debug] Country: FR
[plugins.pluzz][debug] Video ID: c7833bef-2639-466d-b96f-3dbae6222c4c
error: Unable to validate response text: ValidationError(dict):
  Unable to validate value of key 'video'
  Context(dict):
    Unable to validate value of key 'workflow'
    Context(AnySchema):
      ValidationError(equality):
        None does not equal 'token-akamai'
      ValidationError(equality):
        None does not equal 'dai'
```



Discussion:
Perhaps related to this issue https://github.com/yt-dlp/yt-dlp/issues/10197

The workaround given in the issue seems to work for `yt-dlp` but not for `streamlink`:

```
python3 -m pip install -U --pre "yt-dlp[default]"
```

_Originally posted by @bashonly in https://github.com/yt-dlp/yt-dlp/issues/10197#issuecomment-2172114541_



Patch:
diff --git a/src/streamlink/plugins/pluzz.py b/src/streamlink/plugins/pluzz.py
index ce8de45f0e5..4b7c6efd206 100644
--- a/src/streamlink/plugins/pluzz.py
+++ b/src/streamlink/plugins/pluzz.py
@@ -16,23 +16,23 @@
 from streamlink.stream.dash import DASHStream
 from streamlink.stream.hls import HLSStream
 from streamlink.utils.times import localnow
-from streamlink.utils.url import update_qsd
 
 
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(r"""
-    https?://(?:
-        (?:www\.)?france\.tv/
-        |
-        (?:.+\.)?francetvinfo\.fr/
-    )
-""", re.VERBOSE))
+@pluginmatcher(
+    name="francetv",
+    pattern=re.compile(r"https?://(?:[\w-]+\.)?france\.tv/"),
+)
+@pluginmatcher(
+    name="francetvinfofr",
+    pattern=re.compile(r"https?://(?:[\w-]+\.)?francetvinfo\.fr/"),
+)
 class Pluzz(Plugin):
     PLAYER_VERSION = "5.51.35"
     GEO_URL = "https://geoftv-a.akamaihd.net/ws/edgescape.json"
-    API_URL = "https://player.webservices.francetelevisions.fr/v1/videos/{video_id}"
+    API_URL = "https://k7.ftven.fr/videos/{video_id}"
 
     def _get_streams(self):
         self.session.http.headers.update({
@@ -56,6 +56,7 @@ def _get_streams(self):
             video_id = self.session.http.get(self.url, schema=validate.Schema(
                 validate.parse_html(),
                 validate.any(
+                    # default francetv player
                     validate.all(
                         validate.xml_xpath_string(".//script[contains(text(),'window.FTVPlayerVideos')][1]/text()"),
                         str,
@@ -68,18 +69,19 @@ def _get_streams(self):
                         [{"videoId": str}],
                         validate.get((0, "videoId")),
                     ),
+                    # francetvinfo.fr overseas live stream
                     validate.all(
-                        validate.xml_xpath_string(".//script[contains(text(),'new Magnetoscope')][1]/text()"),
+                        validate.xml_xpath_string(".//script[contains(text(),'magneto:{videoId:')][1]/text()"),
                         str,
-                        validate.regex(re.compile(
-                            r"""player\.load\s*\(\s*{\s*src\s*:\s*(?P<q>['"])(?P<video_id>.+?)(?P=q)\s*}\s*\)\s*;""",
-                        )),
+                        validate.regex(re.compile(r"""magneto:\{videoId:(?P<q>['"])(?P<video_id>.+?)(?P=q)""")),
                         validate.get("video_id"),
                     ),
+                    # francetvinfo.fr news article
                     validate.all(
                         validate.xml_xpath_string(".//*[@id][contains(@class,'francetv-player-wrapper')][1]/@id"),
                         str,
                     ),
+                    # francetvinfo.fr videos
                     validate.all(
                         validate.xml_xpath_string(".//*[@data-id][contains(@class,'magneto')][1]/@data-id"),
                         str,
@@ -92,47 +94,54 @@ def _get_streams(self):
             return
         log.debug(f"Video ID: {video_id}")
 
-        api_url = update_qsd(self.API_URL.format(video_id=video_id), {
-            "country_code": country_code,
-            "w": 1920,
-            "h": 1080,
-            "player_version": self.PLAYER_VERSION,
-            "domain": urlparse(self.url).netloc,
-            "device_type": "mobile",
-            "browser": "chrome",
-            "browser_version": CHROME_VERSION,
-            "os": "ios",
-            "gmt": localnow().strftime("%z"),
-        })
-        video_format, token_url, url, self.title = self.session.http.get(api_url, schema=validate.Schema(
-            validate.parse_json(),
-            {
-                "video": {
-                    "workflow": validate.any("token-akamai", "dai"),
-                    "format": validate.any("dash", "hls"),
-                    "token": validate.url(),
-                    "url": validate.url(),
-                },
-                "meta": {
-                    "title": str,
-                },
+        video_format, token_url, url, self.title = self.session.http.get(
+            self.API_URL.format(video_id=video_id),
+            params={
+                "country_code": country_code,
+                "w": 1920,
+                "h": 1080,
+                "player_version": self.PLAYER_VERSION,
+                "domain": urlparse(self.url).netloc,
+                "device_type": "mobile",
+                "browser": "chrome",
+                "browser_version": CHROME_VERSION,
+                "os": "ios",
+                "gmt": localnow().strftime("%z"),
             },
-            validate.union_get(
-                ("video", "format"),
-                ("video", "token"),
-                ("video", "url"),
-                ("meta", "title"),
+            schema=validate.Schema(
+                validate.parse_json(),
+                {
+                    "video": {
+                        "format": validate.any("dash", "hls"),
+                        "token": {
+                            "akamai": validate.url(),
+                        },
+                        "url": validate.url(),
+                    },
+                    "meta": {
+                        "title": str,
+                    },
+                },
+                validate.union_get(
+                    ("video", "format"),
+                    ("video", "token", "akamai"),
+                    ("video", "url"),
+                    ("meta", "title"),
+                ),
             ),
-        ))
+        )
 
-        data_url = update_qsd(token_url, {
-            "url": url,
-        })
-        video_url = self.session.http.get(data_url, schema=validate.Schema(
-            validate.parse_json(),
-            {"url": validate.url()},
-            validate.get("url"),
-        ))
+        video_url = self.session.http.get(
+            token_url,
+            params={
+                "url": url,
+            },
+            schema=validate.Schema(
+                validate.parse_json(),
+                {"url": validate.url()},
+                validate.get("url"),
+            ),
+        )
 
         if video_format == "dash":
             yield from DASHStream.parse_manifest(self.session, video_url).items()


Test patch:
diff --git a/tests/plugins/test_pluzz.py b/tests/plugins/test_pluzz.py
index f1e2e4ce29c..b5e47915ca4 100644
--- a/tests/plugins/test_pluzz.py
+++ b/tests/plugins/test_pluzz.py
@@ -6,17 +6,31 @@ class TestPluginCanHandleUrlPluzz(PluginCanHandleUrl):
     __plugin__ = Pluzz
 
     should_match = [
-        "https://www.france.tv/france-2/direct.html",
-        "https://www.france.tv/france-3/direct.html",
-        "https://www.france.tv/france-4/direct.html",
-        "https://www.france.tv/france-5/direct.html",
-        "https://www.france.tv/franceinfo/direct.html",
-        "https://www.france.tv/france-2/journal-20h00/141003-edition-du-lundi-8-mai-2017.html",
-        "https://france3-regions.francetvinfo.fr/bourgogne-franche-comte/direct/franche-comte",
-        "https://www.francetvinfo.fr/en-direct/tv.html",
-        "https://www.francetvinfo.fr/meteo/orages/inondations-dans-le-gard-plus-de-deux-mois-de-pluie-en-quelques-heures-des"
-        + "-degats-mais-pas-de-victime_4771265.html",
-        "https://la1ere.francetvinfo.fr/info-en-continu-24-24",
-        "https://la1ere.francetvinfo.fr/programme-video/france-3_outremer-ledoc/diffusion/"
-        + "2958951-polynesie-les-sages-de-l-ocean.html",
+        ("francetv", "https://www.france.tv/france-2/direct.html"),
+        ("francetv", "https://www.france.tv/france-3/direct.html"),
+        ("francetv", "https://www.france.tv/france-4/direct.html"),
+        ("francetv", "https://www.france.tv/france-5/direct.html"),
+        ("francetv", "https://www.france.tv/franceinfo/direct.html"),
+        ("francetv", "https://www.france.tv/france-2/journal-20h00/141003-edition-du-lundi-8-mai-2017.html"),
+
+        (
+            "francetvinfofr",
+            "https://www.francetvinfo.fr/en-direct/tv.html",
+        ),
+        (
+            "francetvinfofr",
+            "https://www.francetvinfo.fr/meteo/orages/inondations-dans-le-gard-plus-de-deux-mois-de-pluie-en-quelques-heures-des-degats-mais-pas-de-victime_4771265.html",
+        ),
+        (
+            "francetvinfofr",
+            "https://france3-regions.francetvinfo.fr/bourgogne-franche-comte/direct/franche-comte",
+        ),
+        (
+            "francetvinfofr",
+            "https://la1ere.francetvinfo.fr/programme-video/france-3_outremer-ledoc/diffusion/2958951-polynesie-les-sages-de-l-ocean.html",
+        ),
+        (
+            "francetvinfofr",
+            "https://la1ere.francetvinfo.fr/info-en-continu-24-24",
+        ),
     ]