Repository: streamlink/streamlink

Issue:
htv.com.vn / htv-livecdn.fptplay.net/htvonline
### 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!
I'd like to add the following plugin:
https://htv-livecdn.fptplay.net/htvonline
in order to get the following channel:
- **HTV7**: http://www.htv.com.vn/truc-tuyen

### Input URLs

http://www.htv.com.vn/truc-tuyen


Discussion:
Looks like we can use regex to target `iosUrl` and `iosUrl_mb` javascript variable that stores m3u8 urls. Upon using SoftEther VPN with public VPN Gate lists it kinda worked (for `iosUrl_mb` only, no idea why I got `ERR_HTTP2_PROTOCOL_ERROR` for `iosUrl`).
![image](https://user-images.githubusercontent.com/11626920/147865077-f4889387-ae32-4f25-b267-3bf5aebea70a.png)


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?

The annoying thing about this site is that there are apparently no canonical URLs for each of the 4 channels.  This means a plugin given the URL: `https://www.htv.com.vn/truc-tuyen` will only play the channel that the page starts with by default (currently HTV7).  The only way around this would be to add a plugin command line option to select the other channels.  I don't know if the Streamlink maintainers would approve of that solution though.


Resolving patch:
diff --git a/src/streamlink/plugins/htv.py b/src/streamlink/plugins/htv.py
new file mode 100644
--- /dev/null
+++ b/src/streamlink/plugins/htv.py
@@ -0,0 +1,99 @@
+"""
+$description Vietnamese live TV channels owned by the People's Committee of Ho Chi Minh City.
+$url htv.com.vn
+$type live
+$region Vietnam
+"""
+
+import logging
+import re
+from datetime import date
+
+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\.)?htv\.com\.vn/truc-tuyen(?:\?channel=(?P<channel>\w+)&?|$)"
+))
+class HTV(Plugin):
+    hls_url_re = re.compile(r'var\s+iosUrl\s*=\s*"([^"]+)"')
+
+    def get_channels(self):
+        data = self.session.http.get(self.url, schema=validate.Schema(
+            validate.parse_html(),
+            validate.xml_xpath(".//*[contains(@class,'channel-list')]//a[@data-id][@data-code]"),
+            [
+                validate.union_get("data-id", "data-code"),
+            ],
+        ))
+
+        return {k: v for k, v in data}
+
+    def _get_streams(self):
+        channels = self.get_channels()
+
+        if not channels:
+            log.error("No channels found")
+            return
+
+        log.debug(f"channels={channels}")
+
+        channel_id = self.match.group("channel")
+        if channel_id is None:
+            channel_id, channel_code = next(iter(channels.items()))
+        elif channel_id in channels:
+            channel_code = channels[channel_id]
+        else:
+            log.error(f"Unknown channel ID: {channel_id}")
+            return
+
+        log.info(f"Channel: {channel_code}")
+
+        json = self.session.http.post(
+            "https://www.htv.com.vn/HTVModule/Services/htvService.aspx",
+            data={
+                "method": "GetScheduleList",
+                "channelid": channel_id,
+                "template": "AjaxSchedules.xslt",
+                "channelcode": channel_code,
+                "date": date.today().strftime("%d-%m-%Y"),
+            },
+            schema=validate.Schema(
+                validate.parse_json(),
+                {
+                    "success": bool,
+                    "chanelUrl": validate.url(),
+                },
+            ),
+        )
+
+        if not json["success"]:
+            log.error("API error: success not true")
+            return
+
+        hls_url = self.session.http.get(
+            json["chanelUrl"],
+            headers={"Referer": self.url},
+            schema=validate.Schema(
+                validate.parse_html(),
+                validate.xml_xpath_string(".//script[contains(text(), 'playlist.m3u8')]/text()"),
+                validate.any(None, validate.all(
+                    validate.transform(self.hls_url_re.search),
+                    validate.any(None, validate.all(validate.get(1), validate.url())),
+                )),
+            ),
+        )
+
+        if hls_url:
+            return HLSStream.parse_variant_playlist(
+                self.session,
+                hls_url,
+                headers={"Referer": "https://hplus.com.vn/"},
+            )
+
+
+__plugin__ = HTV


Test patch:
diff --git a/tests/plugins/test_htv.py b/tests/plugins/test_htv.py
new file mode 100644
--- /dev/null
+++ b/tests/plugins/test_htv.py
@@ -0,0 +1,24 @@
+from streamlink.plugins.htv import HTV
+from tests.plugins import PluginCanHandleUrl
+
+
+class TestPluginCanHandleUrlHTV(PluginCanHandleUrl):
+    __plugin__ = HTV
+
+    should_match_groups = [
+        ("https://htv.com.vn/truc-tuyen", {}),
+        ("https://htv.com.vn/truc-tuyen?channel=123", {"channel": "123"}),
+        ("https://htv.com.vn/truc-tuyen?channel=123&foo", {"channel": "123"}),
+        ("https://www.htv.com.vn/truc-tuyen", {}),
+        ("https://www.htv.com.vn/truc-tuyen?channel=123", {"channel": "123"}),
+        ("https://www.htv.com.vn/truc-tuyen?channel=123&foo", {"channel": "123"}),
+    ]
+
+    should_not_match = [
+        "https://htv.com.vn/",
+        "https://htv.com.vn/any/path",
+        "https://htv.com.vn/truc-tuyen?foo",
+        "https://www.htv.com.vn/",
+        "https://www.htv.com.vn/any/path",
+        "https://www.htv.com.vn/truc-tuyen?foo",
+    ]