Repository: streamlink/streamlink

Issue:
plugins.pluzz: doesn't work anymore
### Checklist

- [X] This is a plugin issue and not a different kind of issue
- [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

Latest stable release

### Description

This plugin stopped working last week. The workflow is the same as current plugin, but API_URL and the json response format have changed, leading to errors and failure to get the streams

I've fixed the code and it's working so far on live streams and on the few vod videos i've checked. I can do a PR if you want, but i won't be able to perform current functionalities coverage evaluation before a long time.

### Debug log

```text
[cli][debug] OS:         Linux-5.10.0-8-amd64-x86_64-with-glibc2.31
[cli][debug] Python:     3.9.7
[cli][debug] Streamlink: 2.4.0
[cli][debug] Requests(2.26.0), Socks(1.7.1), Websocket(1.2.1)
[cli][debug] Arguments:
[cli][debug]  url=https://www.france.tv/france-3/direct.html
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin pluzz for URL https://www.france.tv/france-3/direct.html
[plugins.pluzz][debug] Country: FR
[plugins.pluzz][debug] Video ID: 29bdf749-7082-4426-a4f3-595cc436aa0d
error: Unable to open URL: http://sivideo.webservices.francetelevisions.fr/tools/getInfosOeuvre/v2/?idDiffusion=29bdf749-7082-4426-a4f3-595cc436aa0d (404 Client Error: Not Found for url: http://sivideo.webservices.francetelevisions.fr/tools/getInfosOeuvre/v2/?idDiffusion=29bdf749-7082-4426-a4f3-595cc436aa0d)
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/pluzz.py b/src/streamlink/plugins/pluzz.py
--- a/src/streamlink/plugins/pluzz.py
+++ b/src/streamlink/plugins/pluzz.py
@@ -1,216 +1,132 @@
 import logging
 import re
-import sys
-import time
+from datetime import datetime
+from urllib.parse import urlparse
 
-from streamlink.plugin import Plugin, PluginArgument, PluginArguments, pluginmatcher
-from streamlink.plugin.api import validate
-from streamlink.stream import DASHStream, HDSStream, HLSStream, HTTPStream
-from streamlink.stream.ffmpegmux import MuxedStream
+from isodate import LOCAL as LOCALTIMEZONE
+
+from streamlink.plugin import Plugin, PluginError, pluginmatcher
+from streamlink.plugin.api import useragents, validate
+from streamlink.stream import DASHStream, HLSStream
+from streamlink.utils.url import update_qsd
 
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(r'''
-    https?://(
-        (?:www\.)?france\.tv/.+\.html
-        |
-        www\.(ludo|zouzous)\.fr/heros/[\w-]+
+@pluginmatcher(re.compile(r"""
+    https?://(?:
+        (?:www\.)?france\.tv/
         |
-        (.+\.)?francetvinfo\.fr
+        (?:.+\.)?francetvinfo\.fr/
     )
-''', re.VERBOSE))
+""", re.VERBOSE))
 class Pluzz(Plugin):
-    GEO_URL = 'http://geo.francetv.fr/ws/edgescape.json'
-    API_URL = 'http://sivideo.webservices.francetelevisions.fr/tools/getInfosOeuvre/v2/?idDiffusion={0}'
-    TOKEN_URL = 'http://hdfauthftv-a.akamaihd.net/esi/TA?url={0}'
-    SWF_PLAYER_URL = 'https://staticftv-a.akamaihd.net/player/bower_components/player_flash/dist/' \
-                     'FranceTVNVPVFlashPlayer.akamai-7301b6035a43c4e29b7935c9c36771d2.swf'
-
-    _pluzz_video_id_re = re.compile(r'''(?P<q>["']*)videoId(?P=q):\s*["'](?P<video_id>[^"']+)["']''')
-    _jeunesse_video_id_re = re.compile(r'playlist: \[{.*?,"identity":"(?P<video_id>.+?)@(?P<catalogue>Ludo|Zouzous)"')
-    _sport_video_id_re = re.compile(r'data-video="(?P<video_id>.+?)"')
-    _embed_video_id_re = re.compile(r'href="http://videos\.francetv\.fr/video/(?P<video_id>.+?)(?:@.+?)?"')
-    _hds_pv_data_re = re.compile(r"~data=.+?!")
-    _mp4_bitrate_re = re.compile(r'.*-(?P<bitrate>[0-9]+k)\.mp4')
-
-    _geo_schema = validate.Schema({
-        'reponse': {
-            'geo_info': {
-                'country_code': validate.text
-            }
-        }
-    })
-
-    _api_schema = validate.Schema({
-        'videos': validate.all(
-            [{
-                'format': validate.any(
-                    None,
-                    validate.text
-                ),
-                'url': validate.any(
-                    None,
-                    validate.url(),
-                ),
-                'statut': validate.text,
-                'drm': bool,
-                'geoblocage': validate.any(
-                    None,
-                    [validate.all(validate.text)]
-                ),
-                'plages_ouverture': validate.all(
-                    [{
-                        'debut': validate.any(
-                            None,
-                            int
-                        ),
-                        'fin': validate.any(
-                            None,
-                            int
-                        )
-                    }]
-                )
-            }]
-        ),
-        'subtitles': validate.any(
-            [],
-            validate.all(
-                [{
-                    'type': validate.text,
-                    'url': validate.url(),
-                    'format': validate.text
-                }]
-            )
-        )
-    })
-
-    _player_schema = validate.Schema({'result': validate.url()})
+    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}"
 
-    arguments = PluginArguments(
-        PluginArgument("mux-subtitles", is_global=True)
-    )
+    _re_ftv_player_videos = re.compile(r"window\.FTVPlayerVideos\s*=\s*(?P<json>\[{.+?}])\s*;\s*(?:$|var)", re.DOTALL)
+    _re_player_load = re.compile(r"""player\.load\s*\(\s*{\s*src\s*:\s*(['"])(?P<video_id>.+?)\1\s*}\s*\)\s*;""")
 
     def _get_streams(self):
+        self.session.http.headers.update({
+            "User-Agent": useragents.CHROME
+        })
+        CHROME_VERSION = re.compile(r"Chrome/(\d+)").search(useragents.CHROME).group(1)
+
         # Retrieve geolocation data
-        res = self.session.http.get(self.GEO_URL)
-        geo = self.session.http.json(res, schema=self._geo_schema)
-        country_code = geo['reponse']['geo_info']['country_code']
-        log.debug('Country: {0}'.format(country_code))
+        country_code = self.session.http.get(self.GEO_URL, schema=validate.Schema(
+            validate.parse_json(),
+            {"reponse": {"geo_info": {
+                "country_code": str
+            }}},
+            validate.get(("reponse", "geo_info", "country_code"))
+        ))
+        log.debug(f"Country: {country_code}")
 
         # Retrieve URL page and search for video ID
-        res = self.session.http.get(self.url)
-        if 'france.tv' in self.url:
-            match = self._pluzz_video_id_re.search(res.text)
-        elif 'ludo.fr' in self.url or 'zouzous.fr' in self.url:
-            match = self._jeunesse_video_id_re.search(res.text)
-        elif 'sport.francetvinfo.fr' in self.url:
-            match = self._sport_video_id_re.search(res.text)
-        else:
-            match = self._embed_video_id_re.search(res.text)
-        if match is None:
+        video_id = None
+        try:
+            video_id = self.session.http.get(self.url, schema=validate.Schema(
+                validate.parse_html(),
+                validate.any(
+                    validate.all(
+                        validate.xml_xpath_string(".//script[contains(text(),'window.FTVPlayerVideos')][1]/text()"),
+                        str,
+                        validate.transform(self._re_ftv_player_videos.search),
+                        validate.get("json"),
+                        validate.parse_json(),
+                        [{"videoId": str}],
+                        validate.get((0, "videoId"))
+                    ),
+                    validate.all(
+                        validate.xml_xpath_string(".//script[contains(text(),'new Magnetoscope')][1]/text()"),
+                        str,
+                        validate.transform(self._re_player_load.search),
+                        validate.get("video_id")
+                    ),
+                    validate.all(
+                        validate.xml_xpath_string(".//*[@id][contains(@class,'francetv-player-wrapper')][1]/@id"),
+                        str
+                    ),
+                    validate.all(
+                        validate.xml_xpath_string(".//*[@data-id][@class='magneto'][1]/@data-id"),
+                        str
+                    )
+                )
+            ))
+        except PluginError:
+            pass
+        if not video_id:
             return
-        video_id = match.group('video_id')
-        log.debug('Video ID: {0}'.format(video_id))
-
-        res = self.session.http.get(self.API_URL.format(video_id))
-        videos = self.session.http.json(res, schema=self._api_schema)
-        now = time.time()
-
-        offline = False
-        geolocked = False
-        drm = False
-        expired = False
-
-        streams = []
-        for video in videos['videos']:
-            log.trace('{0!r}'.format(video))
-            video_url = video['url']
-
-            # Check whether video format is available
-            if video['statut'] != 'ONLINE':
-                offline = offline or True
-                continue
-
-            # Check whether video format is geo-locked
-            if video['geoblocage'] is not None and country_code not in video['geoblocage']:
-                geolocked = geolocked or True
-                continue
-
-            # Check whether video is DRM-protected
-            if video['drm']:
-                drm = drm or True
-                continue
-
-            # Check whether video format is expired
-            available = False
-            for interval in video['plages_ouverture']:
-                available = (interval['debut'] or 0) <= now <= (interval['fin'] or sys.maxsize)
-                if available:
-                    break
-            if not available:
-                expired = expired or True
-                continue
-
-            res = self.session.http.get(self.TOKEN_URL.format(video_url))
-            video_url = res.text
-
-            if '.mpd' in video_url:
-                # Get redirect video URL
-                res = self.session.http.get(res.text)
-                video_url = res.url
-                for bitrate, stream in DASHStream.parse_manifest(self.session,
-                                                                 video_url).items():
-                    streams.append((bitrate, stream))
-            elif '.f4m' in video_url:
-                for bitrate, stream in HDSStream.parse_manifest(self.session,
-                                                                video_url,
-                                                                is_akamai=True,
-                                                                pvswf=self.SWF_PLAYER_URL).items():
-                    # HDS videos with data in their manifest fragment token
-                    # doesn't seem to be supported by HDSStream. Ignore such
-                    # stream (but HDS stream having only the hdntl parameter in
-                    # their manifest token will be provided)
-                    pvtoken = stream.request_params['params'].get('pvtoken', '')
-                    match = self._hds_pv_data_re.search(pvtoken)
-                    if match is None:
-                        streams.append((bitrate, stream))
-            elif '.m3u8' in video_url:
-                for stream in HLSStream.parse_variant_playlist(self.session, video_url).items():
-                    streams.append(stream)
-            # HBB TV streams are not provided anymore by France Televisions
-            elif '.mp4' in video_url and '/hbbtv/' not in video_url:
-                match = self._mp4_bitrate_re.match(video_url)
-                if match is not None:
-                    bitrate = match.group('bitrate')
-                else:
-                    # Fallback bitrate (seems all France Televisions MP4 videos
-                    # seem have such bitrate)
-                    bitrate = '1500k'
-                streams.append((bitrate, HTTPStream(self.session, video_url)))
-
-        if self.get_option("mux_subtitles") and videos['subtitles'] != []:
-            substreams = {}
-            for subtitle in videos['subtitles']:
-                # TTML subtitles are available but not supported by FFmpeg
-                if subtitle['format'] == 'ttml':
-                    continue
-                substreams[subtitle['type']] = HTTPStream(self.session, subtitle['url'])
-
-            for quality, stream in streams:
-                yield quality, MuxedStream(self.session, stream, subtitles=substreams)
-        else:
-            for stream in streams:
-                yield stream
-
-        if offline:
-            log.error('Failed to access stream, may be due to offline content')
-        if geolocked:
-            log.error('Failed to access stream, may be due to geo-restricted content')
-        if drm:
-            log.error('Failed to access stream, may be due to DRM-protected content')
-        if expired:
-            log.error('Failed to access stream, may be due to expired content')
+        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": datetime.now(tz=LOCALTIMEZONE).strftime("%z")
+        })
+        video_format, token_url, url, self.title = self.session.http.get(api_url, schema=validate.Schema(
+            validate.parse_json(),
+            {
+                "video": {
+                    "workflow": "token-akamai",
+                    "format": validate.any("dash", "hls"),
+                    "token": validate.url(),
+                    "url": validate.url()
+                },
+                "meta": {
+                    "title": str
+                }
+            },
+            validate.union_get(
+                ("video", "format"),
+                ("video", "token"),
+                ("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")
+        ))
+
+        if video_format == "dash":
+            yield from DASHStream.parse_manifest(self.session, video_url).items()
+        elif video_format == "hls":
+            yield from HLSStream.parse_variant_playlist(self.session, video_url).items()
 
 
 __plugin__ = Pluzz


Test patch:
diff --git a/tests/plugins/test_pluzz.py b/tests/plugins/test_pluzz.py
--- a/tests/plugins/test_pluzz.py
+++ b/tests/plugins/test_pluzz.py
@@ -8,29 +8,12 @@ class TestPluginCanHandleUrlPluzz(PluginCanHandleUrl):
     should_match = [
         "https://www.france.tv/france-2/direct.html",
         "https://www.france.tv/france-3/direct.html",
-        "https://www.france.tv/france-3-franche-comte/direct.html",
         "https://www.france.tv/france-4/direct.html",
         "https://www.france.tv/france-5/direct.html",
-        "https://www.france.tv/france-o/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://www.france.tv/france-o/underground/saison-1/132187-underground.html",
-        "http://www.ludo.fr/heros/the-batman",
-        "http://www.ludo.fr/heros/il-etait-une-fois-la-vie",
-        "http://www.zouzous.fr/heros/oui-oui",
-        "http://www.zouzous.fr/heros/marsupilami-1",
-        "http://france3-regions.francetvinfo.fr/bourgogne-franche-comte/tv/direct/franche-comte",
-        "http://sport.francetvinfo.fr/roland-garros/direct",
-        "http://sport.francetvinfo.fr/roland-garros/live-court-3",
-        "http://sport.francetvinfo.fr/roland-garros/andy-murray-gbr-1-andrey-kuznetsov-rus-1er-tour-court"
-        + "-philippe-chatrier",
-        "https://www.francetvinfo.fr/en-direct/tv.html"
-    ]
-
-    should_not_match = [
-        "http://www.france.tv/",
-        "http://pluzz.francetv.fr/",
-        "http://www.ludo.fr/",
-        "http://www.ludo.fr/jeux",
-        "http://www.zouzous.fr/",
+        "https://france3-regions.francetvinfo.fr/bourgogne-franche-comte/tv/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"
     ]