Repository: streamlink/streamlink

Issue:
plugins.tf1: add support for "stream" VODs
### Checklist

- [X] This is a plugin request 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 requests](https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+request%22)

### Description

Hi Team,

French channel provider TF1 launched a new AVOD service called "stream"
Maybe should be possible to upddate current plugin to support them.


### Input URLs

https://www.tf1.fr/stream/chante-69061019
https://www.tf1.fr/stream/thriller-fiction-89242722
https://www.tf1.fr/stream/classique-fiction-83644275
and a lot more....
plugins.tf1 : add 'Stream' channels
<!--
Thanks for opening a pull request!

Before you continue, please make sure that you have read and understood the contribution guidelines, otherwise your changes may be rejected:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

If possible, run the tests, perform code linting and build the documentation locally on your system first to avoid unnecessary build failures:
https://streamlink.github.io/latest/developing.html#validating-changes

Also don't forget to add a meaningful description of your changes, so that the reviewing process is as simple as possible for the maintainers.

Thank you very much!
-->

Allow 'Stream' channels. No account needed



Discussion:
URL presents itself like this : 

https://stream.tf1.fr/v1/v2l/prd-v2l-*show name*-*programID*/dash-hd.mpd?Expires=*time in epoch*&Signature=*most certainly a mixture of server data + account*&Key-Pair-Id=*linked to signature*

you can get the link from there : https://mediainfo.tf1.fr/mediainfocombo/L_FAST_v2l-*show name*-*programID*?pver=4022002&stream_utc_time=*get the time in utc format*&context=MYTF1&topDomain=unknown&platform=web&device=desktop&os=windows&osVersion=10.0&playerVersion=4.22.2&productName=mytf1&productVersion=2.39.7&browser=*user agent*&browserVersion=*user agent major version*
dash url is invalid (only works on TF1 website)
hls url is valid

no account needed (don't understand this label)

in progress https://github.com/streamlink/streamlink/pull/4889
> no account needed (don't understand this label)

Are you using a French IP address?  When I added the label it was because I'd visited the site and it reported: `Pour regarder l’ensemble des replay et le direct sur MYTF1, il vous suffit de vous connecter !` (`To watch all replays and live on MYTF1, all you have to do is log in!`), but that was from a UK IP address.

Checking your updated plugin from a UK IP address:
```
$ streamlink -l debug 'https://www.tf1.fr/stream/chante-69061019'
[session][debug] Plugin tf1 is being overridden by /home/user/.local/share/streamlink/plugins/tf1.py
[cli][debug] OS:         Linux-5.4.0-125-generic-x86_64-with-glibc2.29
[cli][debug] Python:     3.8.10
[cli][debug] Streamlink: 5.0.1+17.gbfcd3725
[cli][debug] Dependencies:
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.0
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.14.1
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.27.1
[cli][debug]  websocket-client: 1.3.2
[cli][debug]  importlib-metadata: 4.11.4
[cli][debug] Arguments:
[cli][debug]  url=https://www.tf1.fr/stream/chante-69061019
[cli][debug]  --loglevel=debug
[cli][debug]  --player=mpv
[cli][info] Found matching plugin tf1 for URL https://www.tf1.fr/stream/chante-69061019
[plugins.tf1][debug] Found channel chante-69061019
error: No playable streams found on this URL: https://www.tf1.fr/stream/chante-69061019
```
So I guess it's due to an IP geo-restriction, which may or may not still  be enforced even when logged into an account, but I won't check that as I don't really want to create an account just for that purpose.

I'll remove the label.



Resolving patch:
diff --git a/src/streamlink/plugins/tf1.py b/src/streamlink/plugins/tf1.py
--- a/src/streamlink/plugins/tf1.py
+++ b/src/streamlink/plugins/tf1.py
@@ -1,6 +1,7 @@
 """
 $description French live TV channels from TF1 Group, including LCI and TF1.
 $url tf1.fr
+$url tf1info.fr
 $url lci.fr
 $type live
 $region France
@@ -10,57 +11,88 @@
 import re
 
 from streamlink.plugin import Plugin, PluginError, pluginmatcher
-from streamlink.plugin.api import useragents
-from streamlink.stream.dash import DASHStream
+from streamlink.plugin.api import useragents, validate
 from streamlink.stream.hls import HLSStream
 
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(
-    r"https?://(?:www\.)?(?:tf1\.fr/([\w-]+)/direct|(lci)\.fr/direct)/?"
-))
+@pluginmatcher(re.compile(r"""
+    https?://(?:www\.)?
+    (?:
+        tf1\.fr/(?:
+            (?P<live>[\w-]+)/direct/?
+            |
+            stream/(?P<stream>[\w-]+)
+        )
+        |
+        (?P<lci>tf1info|lci)\.fr/direct/?
+    )
+""", re.VERBOSE))
 class TF1(Plugin):
-    api_url = "https://mediainfo.tf1.fr/mediainfocombo/{}?context=MYTF1&pver=4001000"
+    _URL_API = "https://mediainfo.tf1.fr/mediainfocombo/{channel_id}"
 
-    def api_call(self, channel, useragent=useragents.CHROME):
-        url = self.api_url.format(f"L_{channel.upper()}")
-        req = self.session.http.get(url,
-                                    headers={"User-Agent": useragent})
-        return self.session.http.json(req)
+    def _get_channel(self):
+        if self.match["live"]:
+            channel = self.match["live"]
+            channel_id = f"L_{channel.upper()}"
+        elif self.match["lci"]:
+            channel = "LCI"
+            channel_id = "L_LCI"
+        elif self.match["stream"]:
+            channel = self.match["stream"]
+            channel_id = f"L_FAST_v2l-{channel}"
+        else:  # pragma: no cover
+            raise PluginError("Invalid channel")
 
-    def get_stream_urls(self, channel):
-        for useragent in [useragents.CHROME, useragents.IPHONE_6]:
-            data = self.api_call(channel, useragent)
+        return channel, channel_id
 
-            if 'delivery' not in data or 'url' not in data['delivery']:
-                continue
-
-            log.debug("Got {format} stream {url}".format(**data['delivery']))
-            yield data['delivery']['format'], data['delivery']['url']
+    def _api_call(self, channel_id):
+        return self.session.http.get(
+            self._URL_API.format(channel_id=channel_id),
+            params={
+                "context": "MYTF1",
+                "pver": "4001000",
+            },
+            headers={
+                # forces HLS streams
+                "User-Agent": useragents.IPHONE,
+            },
+            schema=validate.Schema(
+                validate.parse_json(),
+                {
+                    "delivery": validate.any(
+                        validate.all(
+                            {
+                                "code": 200,
+                                "format": "hls",
+                                "url": validate.url(),
+                            },
+                            validate.union_get("code", "url"),
+                        ),
+                        validate.all(
+                            {
+                                "code": int,
+                                "error": str,
+                            },
+                            validate.union_get("code", "error"),
+                        ),
+                    ),
+                },
+                validate.get("delivery"),
+            ),
+        )
 
     def _get_streams(self):
-        m = self.match
-        if m:
-            channel = m.group(1) or m.group(2)
-            log.debug("Found channel {0}".format(channel))
-            for sformat, url in self.get_stream_urls(channel):
-                try:
-                    if sformat == "dash":
-                        yield from DASHStream.parse_manifest(
-                            self.session,
-                            url,
-                            headers={"User-Agent": useragents.CHROME}
-                        ).items()
-                    if sformat == "hls":
-                        yield from HLSStream.parse_variant_playlist(
-                            self.session,
-                            url,
-                            headers={"User-Agent": useragents.IPHONE},
-                        ).items()
-                except PluginError as e:
-                    log.error("Could not open {0} stream".format(sformat))
-                    log.debug("Failed with error: {0}".format(e))
+        channel, channel_id = self._get_channel()
+        log.debug(f"Found channel {channel} ({channel_id})")
+
+        code, data = self._api_call(channel_id)
+        if code != 200:
+            log.error(data)
+            return
+
+        return HLSStream.parse_variant_playlist(self.session, data)
 
 
 __plugin__ = TF1


Test patch:
diff --git a/tests/plugins/test_tf1.py b/tests/plugins/test_tf1.py
--- a/tests/plugins/test_tf1.py
+++ b/tests/plugins/test_tf1.py
@@ -5,14 +5,19 @@
 class TestPluginCanHandleUrlTF1(PluginCanHandleUrl):
     __plugin__ = TF1
 
-    should_match = [
-        "http://tf1.fr/tf1/direct/",
-        "http://tf1.fr/tfx/direct/",
-        "http://tf1.fr/tf1-series-films/direct/",
-        "http://lci.fr/direct",
-        "http://www.lci.fr/direct",
-        "http://tf1.fr/tmc/direct",
-        "http://tf1.fr/lci/direct",
+    should_match_groups = [
+        ("https://tf1.fr/tf1/direct", {"live": "tf1"}),
+        ("https://www.tf1.fr/tf1/direct", {"live": "tf1"}),
+        ("https://www.tf1.fr/tfx/direct", {"live": "tfx"}),
+        ("https://www.tf1.fr/tf1-series-films/direct", {"live": "tf1-series-films"}),
+
+        ("https://lci.fr/direct", {"lci": "lci"}),
+        ("https://www.lci.fr/direct", {"lci": "lci"}),
+        ("https://tf1info.fr/direct/", {"lci": "tf1info"}),
+        ("https://www.tf1info.fr/direct/", {"lci": "tf1info"}),
+
+        ("https://www.tf1.fr/stream/chante-69061019", {"stream": "chante-69061019"}),
+        ("https://www.tf1.fr/stream/arsene-lupin-39652174", {"stream": "arsene-lupin-39652174"}),
     ]
 
     should_not_match = [