Repository: streamlink/streamlink

Issue:
ltv_lsm_lv.py: Does a typo makes it non-functioning?
### Checklist

- [X] This is a plugin issue 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 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

Latest stable release

### Description

This plugin doesn't work for me on an Enigma2 receiver. When I try to open
#SERVICE 4097:0:1:22B8:446:1:C00000:0:0:0:http%3a//127.0.0.1%3a8088/https%3a//ltv.lsm.lv/lv/tiesraide/ltv1:LTV 1
#DESCRIPTION LTV 1
I get an error message in the debug log (see below). I think this might be caused by a simple typo in the .py file. In line 13 it reads:

 r"https?://ltv\.lsm\.lv/lv/tieshraide"

I do think it should rather be 'tiesraide' instead of 'tieshraide' as the actual URLs of the TV stations are: 

[https://ltv.lsm.lv/lv/tiesraide/ltv1](url)
[https://ltv.lsm.lv/lv/tiesraide/ltv7](url)
[https://ltv.lsm.lv/lv/tiesraide/visiemltv](url)


### Debug log

```text
root@sf4008:~# /usr/sbin/streamlinksrv manualstart debug
pidfile /var/run/streamlink.pid does not exist. Daemon not running?
[streamlinksrv][info]  Sun Oct 31 08:22:04 2021 Server (1.8.2) started
[streamlinksrv][debug] Host:           sf4008
[streamlinksrv][debug] Port:           8088
[streamlinksrv][debug] OS:             Linux-4.1.20-1.9-armv7l-with-glibc2.4
[streamlinksrv][debug] Python:         2.7.18
[streamlinksrv][info]  Streamlink:     1.27.8.0.r1_mod / 2021-10-11
[streamlinksrv][debug] youtube-dl:     2021.06.06
[streamlinksrv][debug] Requests(2.26.0), Socks(1.7.1), Websocket(0.59.0)
[streamlinksrv][error] No plugin can handle URL: https://ltv.lsm.lv/lv/tiesraide/ltv1
[streamlinksrv][debug] Send Offline clip
```



Discussion:
Looks like the website has been updated and the plugin needs to be updated to support this change. Even updating the URL matching does not fix it because the layout of the page appears to have changed. 

Resolving patch:
diff --git a/src/streamlink/plugins/ltv_lsm_lv.py b/src/streamlink/plugins/ltv_lsm_lv.py
--- a/src/streamlink/plugins/ltv_lsm_lv.py
+++ b/src/streamlink/plugins/ltv_lsm_lv.py
@@ -1,45 +1,128 @@
 import logging
 import re
-from urllib.parse import urlparse
+from urllib.parse import urlsplit, urlunsplit
 
 from streamlink.plugin import Plugin, pluginmatcher
-from streamlink.plugin.api.utils import itertags
-from streamlink.stream.hls import HLSStream
+from streamlink.plugin.api import validate
+from streamlink.stream.hls import HLSStream, HLSStreamReader, HLSStreamWorker
 
 log = logging.getLogger(__name__)
 
 
+def copy_query_url(to, from_):
+    """
+    Replace the query string in one URL with the query string from another URL
+    """
+    return urlunsplit(urlsplit(to)._replace(query=urlsplit(from_).query))
+
+
+class LTVHLSStreamWorker(HLSStreamWorker):
+    def process_sequences(self, playlist, sequences):
+        super().process_sequences(playlist, sequences)
+        # update the segment URLs with the query string from the playlist URL
+        self.playlist_sequences = [
+            sequence._replace(
+                segment=sequence.segment._replace(
+                    uri=copy_query_url(sequence.segment.uri, self.stream.url)
+                )
+            )
+            for sequence in self.playlist_sequences
+        ]
+
+
+class LTVHLSStreamReader(HLSStreamReader):
+    __worker__ = LTVHLSStreamWorker
+
+
+class LTVHLSStream(HLSStream):
+    __reader__ = LTVHLSStreamReader
+
+    @classmethod
+    def parse_variant_playlist(cls, *args, **kwargs):
+        streams = super().parse_variant_playlist(*args, **kwargs)
+
+        for stream in streams.values():
+            stream.args["url"] = copy_query_url(stream.args["url"], stream.url_master)
+
+        return streams
+
+
 @pluginmatcher(re.compile(
-    r"https?://ltv\.lsm\.lv/lv/tieshraide"
+    r"https://ltv\.lsm\.lv/lv/tiesraide"
 ))
 class LtvLsmLv(Plugin):
     """
     Support for Latvian live channels streams on ltv.lsm.lv
     """
+
+    def __init__(self, url: str):
+        super().__init__(url)
+        self._json_data_re = re.compile(r'teliaPlayer\((\{.*?\})\);', re.DOTALL)
+
+        self.main_page_schema = validate.Schema(
+            validate.parse_html(),
+            validate.xml_xpath_string(".//iframe[contains(@src, 'ltv.lsm.lv/embed')][1]/@src"),
+            validate.url()
+        )
+
+        self.embed_code_schema = validate.Schema(
+            validate.parse_html(),
+            validate.xml_xpath_string(".//live[1]/@*[name()=':embed-data']"),
+            str,
+            validate.parse_json(),
+            {"source": {"embed_code": str}},
+            validate.get(("source", "embed_code")),
+            validate.parse_html(),
+            validate.xml_xpath_string(".//iframe[@src][1]/@src"),
+        )
+
+        self.player_apicall_schema = validate.Schema(
+            validate.transform(self._json_data_re.search),
+            validate.any(
+                None,
+                validate.all(
+                    validate.get(1),
+                    validate.transform(lambda s: s.replace("'", '"')),
+                    validate.transform(lambda s: re.sub(r",\s*\}", "}", s, flags=re.DOTALL)),
+                    validate.parse_json(),
+                    {"channel": str},
+                    validate.get("channel")
+                )
+            )
+        )
+
+        self.sources_schema = validate.Schema(
+            validate.parse_json(),
+            {
+                "source": {
+                    "sources": validate.all(
+                        [{"type": str, "src": validate.url()}],
+                        validate.filter(lambda src: src["type"] == "application/x-mpegURL"),
+                        validate.map(lambda src: src.get("src"))
+                    ),
+                }},
+            validate.get(("source", "sources"))
+        )
+
     def _get_streams(self):
+        api_url = "https://player.cloudycdn.services/player/ltvlive/channel/{channel_id}/"
+
         self.session.http.headers.update({"Referer": self.url})
 
-        iframe_url = None
-        res = self.session.http.get(self.url)
-        for iframe in itertags(res.text, "iframe"):
-            if "embed.lsm.lv" in iframe.attributes.get("src"):
-                iframe_url = iframe.attributes.get("src")
-                break
-
-        if not iframe_url:
-            log.error("Could not find player iframe")
-            return
-
-        log.debug("Found iframe: {0}".format(iframe_url))
-        res = self.session.http.get(iframe_url)
-        for source in itertags(res.text, "source"):
-            if source.attributes.get("src"):
-                stream_url = source.attributes.get("src")
-                url_path = urlparse(stream_url).path
-                if url_path.endswith(".m3u8"):
-                    yield from HLSStream.parse_variant_playlist(self.session, stream_url).items()
-                else:
-                    log.debug("Not used URL path: {0}".format(url_path))
+        iframe_url = self.session.http.get(self.url, schema=self.main_page_schema)
+        log.debug(f"Found embed iframe: {iframe_url}")
+        player_iframe_url = self.session.http.get(iframe_url, schema=self.embed_code_schema)
+        log.debug(f"Found player iframe: {player_iframe_url}")
+        channel_id = self.session.http.get(player_iframe_url, schema=self.player_apicall_schema)
+        log.debug(f"Found channel ID: {channel_id}")
+
+        stream_sources = self.session.http.post(api_url.format(channel_id=channel_id),
+                                                data=dict(refer="ltv.lsm.lv",
+                                                          playertype="regular",
+                                                          protocol="hls"),
+                                                schema=self.sources_schema)
+        for surl in stream_sources:
+            yield from LTVHLSStream.parse_variant_playlist(self.session, surl).items()
 
 
 __plugin__ = LtvLsmLv


Test patch:
diff --git a/tests/plugins/test_ltv_lsm_lv.py b/tests/plugins/test_ltv_lsm_lv.py
--- a/tests/plugins/test_ltv_lsm_lv.py
+++ b/tests/plugins/test_ltv_lsm_lv.py
@@ -6,10 +6,10 @@ class TestPluginCanHandleUrlLtvLsmLv(PluginCanHandleUrl):
     __plugin__ = LtvLsmLv
 
     should_match = [
-        "https://ltv.lsm.lv/lv/tieshraide/example/",
-        "http://ltv.lsm.lv/lv/tieshraide/example/",
-        "https://ltv.lsm.lv/lv/tieshraide/example/live.123/",
-        "http://ltv.lsm.lv/lv/tieshraide/example/live.123/",
+        "https://ltv.lsm.lv/lv/tiesraide/example/",
+        "https://ltv.lsm.lv/lv/tiesraide/example/",
+        "https://ltv.lsm.lv/lv/tiesraide/example/live.123/",
+        "https://ltv.lsm.lv/lv/tiesraide/example/live.123/",
     ]
 
     should_not_match = [
@@ -21,4 +21,5 @@ class TestPluginCanHandleUrlLtvLsmLv(PluginCanHandleUrl):
         "http://ltv.lsm.lv/other-site/",
         "https://ltv.lsm.lv/lv/other-site/",
         "http://ltv.lsm.lv/lv/other-site/",
+        "https://ltv.lsm.lv/lv/tieshraide/example/",
     ]