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:
uNouss: this revision https://github.com/streamlink/streamlink/pull/6048 fix the issue #6047 

Patch:
diff --git a/src/streamlink/plugins/pluzz.py b/src/streamlink/plugins/pluzz.py
index ce8de45f..4b7c6efd 100644
--- a/src/streamlink/plugins/pluzz.py
+++ b/src/streamlink/plugins/pluzz.py
@@ -16,23 +16,23 @@ from streamlink.plugin.api import useragents, validate
 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 @@ class Pluzz(Plugin):
             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 @@ class Pluzz(Plugin):
                         [{"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 @@ class Pluzz(Plugin):
             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()
diff --git a/src/streamlink/plugins/twitch.py b/src/streamlink/plugins/twitch.py
index 7ae078e9..06278b09 100644
--- a/src/streamlink/plugins/twitch.py
+++ b/src/streamlink/plugins/twitch.py
@@ -71,26 +71,45 @@ class TwitchM3U8Parser(M3U8Parser[TwitchM3U8, TwitchHLSSegment, HLSPlaylist]):
     __m3u8__: ClassVar[Type[TwitchM3U8]] = TwitchM3U8
     __segment__: ClassVar[Type[TwitchHLSSegment]] = TwitchHLSSegment
 
+    @parse_tag("EXT-X-TWITCH-LIVE-SEQUENCE")
+    def parse_ext_x_twitch_live_sequence(self, value):
+        # Unset discontinuity state if the previous segment was not an ad,
+        # as the following segment won't be an ad
+        if self.m3u8.segments and not self.m3u8.segments[-1].ad:
+            self._discontinuity = False
+
     @parse_tag("EXT-X-TWITCH-PREFETCH")
     def parse_tag_ext_x_twitch_prefetch(self, value):
         segments = self.m3u8.segments
         if not segments:  # pragma: no cover
             return
         last = segments[-1]
+
         # Use the average duration of all regular segments for the duration of prefetch segments.
         # This is better than using the duration of the last segment when regular segment durations vary a lot.
         # In low latency mode, the playlist reload time is the duration of the last segment.
         duration = last.duration if last.prefetch else sum(segment.duration for segment in segments) / float(len(segments))
+
         # Use the last duration for extrapolating the start time of the prefetch segment, which is needed for checking
         # whether it is an ad segment and matches the parsed date ranges or not
         date = last.date + timedelta(seconds=last.duration)
+
         # Always treat prefetch segments after a discontinuity as ad segments
+        # (discontinuity tag inserted after last regular segment)
+        # Don't reset discontinuity state: the date extrapolation might be inaccurate,
+        # so all following prefetch segments should be considered an ad after a discontinuity
         ad = self._discontinuity or self._is_segment_ad(date)
+
+        # Since we don't reset the discontinuity state in prefetch segments for the purpose of ad detection,
+        # set the prefetch segment's discontinuity attribute based on ad transitions
+        discontinuity = ad != last.ad
+
         segment = dataclass_replace(
             last,
             uri=self.uri(value),
             duration=duration,
             title=None,
+            discontinuity=discontinuity,
             date=date,
             ad=ad,
             prefetch=True,
@@ -108,6 +127,15 @@ class TwitchM3U8Parser(M3U8Parser[TwitchM3U8, TwitchHLSSegment, HLSPlaylist]):
         ad = self._is_segment_ad(self._date, self._extinf.title if self._extinf else None)
         segment: TwitchHLSSegment = super().get_segment(uri, ad=ad, prefetch=False)  # type: ignore[assignment]
 
+        # Special case where Twitch incorrectly inserts discontinuity tags between segments of the live content
+        if (
+            segment.discontinuity
+            and not segment.ad
+            and self.m3u8.segments
+            and not self.m3u8.segments[-1].ad
+        ):
+            segment.discontinuity = False
+
         return segment
 
     def _is_segment_ad(self, date: Optional[datetime], title: Optional[str] = None) -> bool:


Test patch:
diff --git a/tests/plugins/test_pluzz.py b/tests/plugins/test_pluzz.py
index f1e2e4ce..b5e47915 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",
+        ),
     ]
diff --git a/tests/plugins/test_twitch.py b/tests/plugins/test_twitch.py
index e8997dbb..00bdd023 100644
--- a/tests/plugins/test_twitch.py
+++ b/tests/plugins/test_twitch.py
@@ -363,6 +363,7 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
             Tag("EXT-X-DISCONTINUITY"),
             TagDateRangeAd(start=DATETIME_BASE + timedelta(seconds=3), duration=4),
         ]
+        tls = Tag("EXT-X-TWITCH-LIVE-SEQUENCE", 7)
         # noinspection PyTypeChecker
         segments = self.subject([
             # regular stream data with prefetch segments
@@ -374,9 +375,9 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
             # still no prefetch segments while ads are playing
             Playlist(3, [*ads, Seg(3), Seg(4), Seg(5), Seg(6)]),
             # new prefetch segments on the first regular segment occurrence
-            Playlist(4, [*ads, Seg(4), Seg(5), Seg(6), Seg(7), Pre(8), Pre(9)]),
-            Playlist(5, [*ads, Seg(5), Seg(6), Seg(7), Seg(8), Pre(9), Pre(10)]),
-            Playlist(6, [*ads, Seg(6), Seg(7), Seg(8), Seg(9), Pre(10), Pre(11)]),
+            Playlist(4, [*ads, Seg(4), Seg(5), Seg(6), tls, Seg(7), Pre(8), Pre(9)]),
+            Playlist(5, [*ads, Seg(5), Seg(6), tls, Seg(7), Seg(8), Pre(9), Pre(10)]),
+            Playlist(6, [*ads, Seg(6), tls, Seg(7), Seg(8), Seg(9), Pre(10), Pre(11)]),
             Playlist(7, [Seg(7), Seg(8), Seg(9), Seg(10), Pre(11), Pre(12)], end=True),
         ], streamoptions={"disable_ads": True, "low_latency": True})
 
@@ -415,6 +416,36 @@ class TestTwitchHLSStream(TestMixinStreamHLS, unittest.TestCase):
         self.await_read(read_all=True)
         assert self.thread.reader.worker.playlist_reload_time == pytest.approx(23 / 3)
 
+    @patch("streamlink.stream.hls.hls.log")
+    def test_hls_prefetch_after_discontinuity(self, mock_log):
+        segments = self.subject([
+            Playlist(0, [Segment(0), Segment(1)]),
+            Playlist(2, [Segment(2), Segment(3), Tag("EXT-X-DISCONTINUITY"), SegmentPrefetch(4), SegmentPrefetch(5)]),
+            Playlist(6, [Segment(6), Segment(7)], end=True),
+        ], streamoptions={"disable_ads": True, "low_latency": True})
+
+        self.await_write(8)
+        assert self.await_read(read_all=True) == self.content(segments, cond=lambda seg: seg.num not in (4, 5))
+        assert mock_log.warning.mock_calls == [
+            call("Encountered a stream discontinuity. This is unsupported and will result in incoherent output data."),
+        ]
+
+    @patch("streamlink.stream.hls.hls.log")
+    def test_hls_ignored_discontinuity(self, mock_log):
+        Seg, Pre = Segment, SegmentPrefetch
+        discontinuity = Tag("EXT-X-DISCONTINUITY")
+        tls = Tag("EXT-X-TWITCH-LIVE-SEQUENCE", 1234)  # value is irrelevant
+        segments = self.subject([
+            Playlist(0, [Seg(0), discontinuity, Seg(1)]),
+            Playlist(2, [Seg(2), Seg(3), discontinuity, Seg(4), Seg(5)]),
+            Playlist(6, [Seg(6), Seg(7), discontinuity, tls, Pre(8), Pre(9)]),
+            Playlist(10, [Seg(10), Seg(11), discontinuity, tls, Pre(12), discontinuity, tls, Pre(13)], end=True),
+        ], streamoptions={"disable_ads": True, "low_latency": True})
+
+        self.await_write(14)
+        assert self.await_read(read_all=True) == self.content(segments)
+        assert mock_log.warning.mock_calls == []
+
 
 class TestTwitchAPIAccessToken:
     @pytest.fixture(autouse=True)