Repository: streamlink/streamlink

Issue:
https://www.cmmedia.es/en-directo/tv / https://klive.kaltura.com
### 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,
This is a plugin development request to include CMM Media in Streamlink. 
CMM Media is a TV Broadcaster of [Castilla La Mancha](url=https://en.wikipedia.org/wiki/Castilla%E2%80%93La_Mancha) region, in Spain.
Is run under the gobernment of the of Castilla La Mancha.
The content provided are basically news, documentaries and special programs from that region of Spain

### Input URLs

Information obtained from VideoDownload Helper:

**masterManifest:**
https://klive.kaltura.com/s/env/cluster-1-b.live.nvp1/live/hls/p/2288691/e/0_xs45iy5i/tl/main/st/0/t/DznN37GHRy-bwqducFv4Xw/index-s32.m3u8?__hdnea__=st=1646058301~exp=1646144701~acl=/s/env/cluster-1-b.live.nvp1/live/hls/p/2288691/e/0_xs45iy5i/tl/main/st/0/t/DznN37GHRy-bwqducFv4Xw/index-s32.m3u8*~hmac=3f226bcf7b99800114dff69ce810b3e3b0f36873eaf3503216917d6ba83f23c6

mediaManifest:
https://klive.kaltura.com/s/env/cluster-1-b.live.nvp1/live/hls/p/2288691/e/0_xs45iy5i/tl/main/st/0/t/DznN37GHRy-bwqducFv4Xw/index-s32.m3u8?__hdnea__=st=1646058301~exp=1646144701~acl=/s/env/cluster-1-b.live.nvp1/live/hls/p/2288691/e/0_xs45iy5i/tl/main/st/0/t/DznN37GHRy-bwqducFv4Xw/index-s32.m3u8*~hmac=3f226bcf7b99800114dff69ce810b3e3b0f36873eaf3503216917d6ba83f23c6

topUrl:
https://www.cmmedia.es/en-directo/tv/

url:
https://klive.kaltura.com/s/env/cluster-1-b.live.nvp1/live/hls/p/2288691/e/0_xs45iy5i/tl/main/st/0/t/DznN37GHRy-bwqducFv4Xw/index-s32.m3u8?__hdnea__=st=1646058301~exp=1646144701~acl=/s/env/cluster-1-b.live.nvp1/live/hls/p/2288691/e/0_xs45iy5i/tl/main/st/0/t/DznN37GHRy-bwqducFv4Xw/index-s32.m3u8*~hmac=3f226bcf7b99800114dff69ce810b3e3b0f36873eaf3503216917d6ba83f23c6

If you need anything else, just let me know.


Discussion:
What's the deal with adding TV channels now?  Since  #4344 was merged I'm a bit confused as to what the current situation is.  Can somebody say yes or no for this, please?


Resolving patch:
diff --git a/src/streamlink/plugins/cmmedia.py b/src/streamlink/plugins/cmmedia.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/plugins/cmmedia.py
@@ -0,0 +1,119 @@
+"""
+$description Live TV channel and video on-demand service from CMM, a Spanish public, state-owned broadcaster.
+$url cmmedia.es
+$type live, vod
+$notes Content not licensed for digital distribution is unavailable via the live channel stream.
+"""
+
+import logging
+import re
+from functools import partial
+
+from streamlink.plugin import Plugin, pluginmatcher
+from streamlink.plugin.api import validate
+from streamlink.stream.hls import HLSStream
+
+log = logging.getLogger(__name__)
+
+
+@pluginmatcher(re.compile(r"https?://(?:www\.)?cmmedia\.es"))
+class CMMedia(Plugin):
+    iframe_url_re = re.compile(r'{getKs\("([^"]+)"')
+    json_re = re.compile(r"twindow\.kalturaIframePackageData\s*=\s*({.*});[\\nt]*var isIE8")
+    unescape_quotes = partial(re.compile(r'\\"').sub, r'"')
+    partner_ids_re = re.compile(r"/p/(\d+)/sp/(\d+)/")
+
+    @staticmethod
+    def is_restricted(data):
+        items = {k: v for k, v in data.items() if k.startswith("is")}
+        for k, v in items.items():
+            log.debug(f"{k}: {v}")
+
+        restrictions = [k for k, v in items.items() if v]
+        if restrictions:
+            log.error(f"This site is restricted: ({', '.join(restrictions)})")
+            return True
+
+        return False
+
+    def _get_streams(self):
+        parsed_html = self.session.http.get(self.url, schema=validate.Schema(validate.parse_html()))
+
+        iframe_url = validate.validate(validate.Schema(
+            validate.xml_xpath_string(".//script[contains(text(), '/embedIframeJs/')]/text()"),
+            validate.any(None, validate.all(
+                validate.transform(self.iframe_url_re.search),
+                validate.any(None, validate.all(validate.get(1), validate.url())),
+            )),
+        ), parsed_html)
+
+        if not iframe_url:
+            return
+
+        m = self.partner_ids_re.search(iframe_url)
+        if not m:
+            log.error("Failed to find partner IDs in IFRAME URL")
+            return
+
+        p = m.group(1)
+        sp = m.group(2)
+
+        json = self.session.http.get(iframe_url, schema=validate.Schema(
+            validate.transform(self.json_re.search),
+            validate.any(None, validate.all(
+                validate.get(1),
+                validate.transform(self.unescape_quotes),
+                validate.parse_json(),
+                validate.any({
+                    "entryResult": {
+                        "contextData": {
+                            "isSiteRestricted": bool,
+                            "isCountryRestricted": bool,
+                            "isSessionRestricted": bool,
+                            "isIpAddressRestricted": bool,
+                            "isUserAgentRestricted": bool,
+                            "flavorAssets": [{
+                                "id": str,
+                            }],
+                        },
+                        "meta": {
+                            "id": str,
+                            "name": str,
+                            "categories": validate.any(None, str),
+                        },
+                    },
+                }, {"error": str}),
+            )),
+        ))
+
+        if not json:
+            return
+
+        if "error" in json:
+            log.error(f"API error: {json['error']}")
+            return
+
+        json = json.get("entryResult")
+
+        if self.is_restricted(json["contextData"]):
+            return
+
+        self.id = json["meta"]["id"]
+        self.title = json["meta"]["name"]
+        self.author = validate.validate(validate.Schema(
+            validate.xml_xpath_string(".//h1[contains(@class, 'btn-title')]/text()"),
+        ), parsed_html)
+        if json["meta"]["categories"]:
+            self.category = json["meta"]["categories"]
+
+        for asset in json["contextData"]["flavorAssets"]:
+            yield from HLSStream.parse_variant_playlist(
+                self.session, (
+                    f"https://cdnapisec.kaltura.com/p/{p}/sp/{sp}/playManifest/entryId/{json['meta']['id']}"
+                    f"/flavorIds/{asset['id']}/format/applehttp/protocol/https/a.m3u8"
+                ),
+                name_fmt="{pixels}_{bitrate}",
+            ).items()
+
+
+__plugin__ = CMMedia


Test patch:
diff --git a/tests/plugins/test_cmmedia.py b/tests/plugins/test_cmmedia.py
new file mode 100644
--- /dev/null
+++ b/tests/plugins/test_cmmedia.py
@@ -0,0 +1,19 @@
+from streamlink.plugins.cmmedia import CMMedia
+from tests.plugins import PluginCanHandleUrl
+
+
+class TestPluginCanHandleUrlCMMedia(PluginCanHandleUrl):
+    __plugin__ = CMMedia
+
+    should_match = [
+        "http://cmmedia.es",
+        "http://www.cmmedia.es",
+        "http://cmmedia.es/any/path",
+        "http://cmmedia.es/any/path?x",
+        "http://www.cmmedia.es/any/path?x&y",
+        "https://cmmedia.es",
+        "https://www.cmmedia.es",
+        "https://cmmedia.es/any/path",
+        "https://cmmedia.es/any/path?x",
+        "https://www.cmmedia.es/any/path?x&y",
+    ]