Repository: streamlink/streamlink

Issue:
plugins.tv3cat: URLs changed
### 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

6.7.0

### Description

https://www.ccma.cat/3cat/directes/tv3/ is new URL of TV3CAT.

### Debug log

```text
H:\101tv>streamlink --loglevel=debug https://www.ccma.cat/3cat/directes/tv3/ best
[cli][debug] OS:         Windows 11
[cli][debug] Python:     3.12.2
[cli][debug] OpenSSL:    OpenSSL 3.0.13 30 Jan 2024
[cli][debug] Streamlink: 6.7.0
[cli][debug] Dependencies:
[cli][debug]  certifi: 2024.2.2
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 5.1.0
[cli][debug]  pycountry: 23.12.11
[cli][debug]  pycryptodome: 3.20.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.24.0
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  typing-extensions: 4.10.0
[cli][debug]  urllib3: 2.2.1
[cli][debug]  websocket-client: 1.7.0
[cli][debug] Arguments:
[cli][debug]  url=https://www.ccma.cat/3cat/directes/tv3/
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --ffmpeg-ffmpeg=C:\Program Files\Streamlink\ffmpeg\ffmpeg.exe
[cli][info] Found matching plugin generic for URL https://www.ccma.cat/3cat/directes/tv3/
[plugins.generic][info] Version 2023-08-24 - https://github.com/back-to/generic
[plugins.generic][info]   1. URL=https://www.ccma.cat/3cat/directes/tv3/
error: No plugin can handle URL: https://www.ccma.cat/3cat/directes/tv3/
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/tv3cat.py b/src/streamlink/plugins/tv3cat.py
--- a/src/streamlink/plugins/tv3cat.py
+++ b/src/streamlink/plugins/tv3cat.py
@@ -8,49 +8,75 @@
 import logging
 import re
 
+from streamlink.exceptions import NoStreamsError, PluginError
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
+from streamlink.stream.dash import DASHStream
 from streamlink.stream.hls import HLSStream
+from streamlink.stream.http import HTTPStream
 
 
 log = logging.getLogger(__name__)
 
 
-@pluginmatcher(re.compile(
-    r"https?://(?:www\.)?ccma\.cat/tv3/directe/(?P<ident>.+?)/",
-))
+@pluginmatcher(
+    name="live",
+    pattern=re.compile(r"https://(?:www)?\.ccma\.cat/3cat/directes/(?P<ident>[^/?]+)"),
+)
+@pluginmatcher(
+    name="vod",
+    pattern=re.compile(r"https://(?:www)?\.ccma\.cat/3cat/[^/]+/video/(?P<ident>\d+)"),
+)
 class TV3Cat(Plugin):
-    _URL_STREAM_INFO = "https://dinamics.ccma.cat/pvideo/media.jsp"
+    _URL_API_GEO = "https://dinamics.ccma.cat/geo.json"
+    _URL_API_MEDIA = "https://api-media.ccma.cat/pvideo/media.jsp"
 
-    _MAP_CHANNELS = {
-        "tv3": "tvi",
+    _MAP_CHANNEL_IDENTS = {
+        "catalunya-radio": "cr",
+        "catalunya-informacio": "ci",
+        "catalunya-musica": "cm",
+        "icat": "ic",
     }
 
-    def _get_streams(self):
-        ident = self.match.group("ident")
+    def _call_api_media(self, fmt, schema, params):
+        geo = self.session.http.get(
+            self._URL_API_GEO,
+            schema=validate.Schema(
+                validate.parse_json(),
+                {"geo": str},
+                validate.get("geo"),
+            ),
+        )
+        if not geo:
+            raise PluginError("Missing 'geo' value")
 
-        schema_media = {
-            "geo": str,
-            "url": validate.url(path=validate.endswith(".m3u8")),
-        }
+        log.debug(f"{geo=}")
+        schema = validate.all(
+            {
+                "geo": str,
+                "format": fmt,
+                "url": schema,
+            },
+            validate.union_get("geo", "url"),
+        )
 
-        stream_infos = self.session.http.get(
-            self._URL_STREAM_INFO,
+        ident = self.match["ident"]
+        streams = self.session.http.get(
+            self._URL_API_MEDIA,
             params={
                 "media": "video",
                 "versio": "vast",
-                "idint": self._MAP_CHANNELS.get(ident, ident),
-                "profile": "pc",
-                "desplacament": "0",
-                "broadcast": "false",
+                "idint": self._MAP_CHANNEL_IDENTS.get(ident, ident),
+                "profile": "pc_3cat",
+                **(params or {}),
             },
             schema=validate.Schema(
                 validate.parse_json(),
                 {
                     "media": validate.any(
-                        [schema_media],
+                        [schema],
                         validate.all(
-                            schema_media,
+                            schema,
                             validate.transform(lambda item: [item]),
                         ),
                     ),
@@ -59,12 +85,41 @@ def _get_streams(self):
             ),
         )
 
-        for stream in stream_infos:
-            log.info(f"Accessing stream from region {stream['geo']}")
-            try:
-                return HLSStream.parse_variant_playlist(self.session, stream["url"], name_fmt="{pixels}_{bitrate}")
-            except OSError:
-                pass
+        log.debug(f"{streams=}")
+        for _geo, data in streams:
+            if _geo == geo:
+                return data
+
+        log.error("The content is geo-blocked")
+        raise NoStreamsError
+
+    def _get_live(self):
+        schema = validate.url(path=validate.endswith(".m3u8"))
+        url = self._call_api_media("HLS", schema, {"desplacament": 0})
+        return HLSStream.parse_variant_playlist(self.session, url)
+
+    def _get_vod(self):
+        schema = [
+            validate.all(
+                {
+                    "label": str,
+                    "file": validate.url(),
+                },
+                validate.union_get("label", "file"),
+            ),
+        ]
+        urls = self._call_api_media("MP4", schema, {"format": "dm"})
+        for label, url in urls:
+            if label == "DASH":
+                yield from DASHStream.parse_manifest(self.session, url).items()
+            else:
+                yield label, HTTPStream(self.session, url)
+
+    def _get_streams(self):
+        if self.matches["live"]:
+            return self._get_live()
+        else:
+            return self._get_vod()
 
 
 __plugin__ = TV3Cat


Test patch:
diff --git a/tests/plugins/test_tv3cat.py b/tests/plugins/test_tv3cat.py
--- a/tests/plugins/test_tv3cat.py
+++ b/tests/plugins/test_tv3cat.py
@@ -6,8 +6,22 @@ class TestPluginCanHandleUrlTV3Cat(PluginCanHandleUrl):
     __plugin__ = TV3Cat
 
     should_match_groups = [
-        ("https://ccma.cat/tv3/directe/tv3/", {"ident": "tv3"}),
-        ("https://ccma.cat/tv3/directe/324/", {"ident": "324"}),
-        ("https://www.ccma.cat/tv3/directe/tv3/", {"ident": "tv3"}),
-        ("https://www.ccma.cat/tv3/directe/324/", {"ident": "324"}),
+        (("live", "https://www.ccma.cat/3cat/directes/tv3/"), {"ident": "tv3"}),
+        (("live", "https://www.ccma.cat/3cat/directes/324/"), {"ident": "324"}),
+        (("live", "https://www.ccma.cat/3cat/directes/esport3/"), {"ident": "esport3"}),
+        (("live", "https://www.ccma.cat/3cat/directes/sx3/"), {"ident": "sx3"}),
+        (("live", "https://www.ccma.cat/3cat/directes/catalunya-radio/"), {"ident": "catalunya-radio"}),
+
+        (
+            ("vod", "https://www.ccma.cat/3cat/t1xc1-arribada/video/6260741/"),
+            {"ident": "6260741"},
+        ),
+        (
+            ("vod", "https://www.ccma.cat/3cat/merli-els-peripatetics-capitol-1/video/5549976/"),
+            {"ident": "5549976"},
+        ),
+        (
+            ("vod", "https://www.ccma.cat/3cat/buscant-la-sostenibilitat-i-la-tecnologia-del-futur/video/6268863/"),
+            {"ident": "6268863"},
+        ),
     ]