Repository: streamlink/streamlink

Issue:
plugins.rtve: ZTNR.translate() runs endlessly
### 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

RTVE plugin is not resolving any streams and stuck after message "Found matching plugin rtve for URL". Upon debugging in Python the following while block seems to cause an endless loop: https://github.com/streamlink/streamlink/blob/master/src/streamlink/plugins/rtve.py#L111

Thanks for the good work!
 
Cheers.

### Debug log

```text
bin\streamlink.exe -l debug https://rtve.es/play/videos/directo/canales-lineales/24h
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.10.7
[cli][debug] Streamlink: 5.0.0
[cli][debug] Dependencies:
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.1
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.15.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.28.1
[cli][debug]  websocket-client: 1.4.1
[cli][debug] Arguments:
[cli][debug]  url=https://rtve.es/play/videos/directo/canales-lineales/24h
[cli][debug]  --loglevel=debug
[cli][info] Found matching plugin rtve for URL https://rtve.es/play/videos/directo/canales-lineales/24h
```



Discussion:
Indeed, there's a bug in the while-loop. That while-loop should be turned into an iterator which yields tuples of chunk types and chunk data, so that the `continue` and `break` statements can be used in the loop body without having to call `read_chunks()` again. This is why there's an infinite loop, because the next data doesn't get read before executing the `continue` statement.

However, the obfuscated data format which is being decoded here seems to have changed, too. As I mentioned in the last plugin issue thread, the only streams which were still relying on this data were some VODs, but live streams were not and a generic HLS URL template could be used instead:
https://github.com/streamlink/streamlink/issues/4757#issuecomment-1221357795

But when looking at the response of the VOD I listed in the linked comment and comparing it to the response of the URL you've posted, something seems to have changed now. As said, if no streams are found in the obfuscated data, then the plugin continues with a generic HLS URL template, but it's possible that stream URLs are still present in the data.

I'll first have to look at the site's JS implementation again before I can submit a PR with a fix for the while-loop.
JS code seems to be unchanged
https://js2.rtve.es/pages/app-player/0.0.79/js/pf_directo.js

```js
    function translate(img, callback) {
        var chunk, reader, arrSources = [], arrCalidades = [], calidad;
        
        try {
            reader = new Base64Reader(img);
            reader.skip(8);
            chunk = readChunk(reader);

            while (chunk.type !== 'IEND') {
                if (chunk.type === 'tEXt') {
                     var data = chunk.data, a = '', i = 0;
                     for (i = 0; i < data.length; ++i){
                        if (data[i] !== 0){
                           a += String.fromCharCode(data[i]);
                        }
                     }
                     calidad = a.indexOf('%%') === -1 ? '' : a.split('#')[1].split('%%')[0];
                     a = a.indexOf('%%') === -1 ? a : a.split('#')[0]+'#'+a.split('#')[1].split('%%')[1];

                     arrCalidades.push(calidad);
                     arrSources.push(getSource(a));
                }
                chunk = readChunk(reader);
            }
        } catch (e){
            console.error('RtvePlayer', 'ztnrThumbnail', 'No se ha podido procesar la img en modeTwo.');
        }      
        callback(arrSources, arrCalidades);
    }
```

Now I really don't know if this whole de-obfuscation implementation is actually still used and could actually be removed.
Thanks for checking. I was about to ask if you need help reviewing the JS obfuscation but seems it is unchanged. On Web site, it's also the URL_M3U8 that are playing, so maybe they don't use the obfuscation anymore. But the obfuscated code is still there. Could be some change in the alfabet used?
> Could be some change in the alfabet used?

The `tEXt` chunks all have a different format. For example:

```
jOe6dr6E-WhfxA4tDLu/E99BOgw0_G07FFlP7-Y-7E8TZsC&l?wTJyHCoQIlyV5n7kbavfHTR78i0G@qcO3vN2bWDnm=L5Z4c2UZMvIUm.KUCBQ7Y5Z_IFLVdL6StTMxNaO-AFi1zj/Xzq:=X=vrAopESpA&ZhXUbHDw0&qn?@KGuJ.NJp.v#016140605667201732857087007702762067310481246733078104011874806022088152481364825411231265137653713378056041462843562350510166001171804117703154601345004618808073143728004840738436180465446316542136880354723226385036911713093320048501151818118010065458537144903936683157425245
```

```
Ws9UIgm3fOLf8odxuj9hvgFTa:wodKq7zK8nh4dim=oD@SXxN0ksQZ&6w@ZEys=F9IBJ&1t72BgC8S64aU&hu796mJp8UI8MC&Z@cistg&lE&DNCdUxHzD8X/.jigYxos5AMe:ywe-8VPpBFo.QLQfGO-oB3Uxx_T1u&DRA:O?bxZm3lYqKr#HD_READY%%056072828835264235110438472876841280385483004777041100281533745780108728158533341716111874515726195072871248032853583585734271467281658342851458532038185746475082794488761315343115176345571400505342181486242383617334460052055646248186340630811485114638636224827722225363122521315462562223710861062456253150681224636371436805518154655713152475815660126420505637003777020416131724112676335266754550515157651315067147202614227352871160855762333135443538012414315513277882527225026836205324360416239
```

I don't know what this data represents and how it gets decoded, but since the JS code still uses the same logic for extracting the stream URLs as before when I implemented it (I've only posted the `translate` function and not the rest), I doubt it's a new format and just something else which we don't need, so basically junk data.

As you said for yourself, the `URL_M3U8`-based HLS URLs are working fine, even for VODs. I'll keep the `ZNTR` implementation included, as it doesn't hurt having it, except for one useless HTTP request and some useless CPU cycles. It's possible that some streams still require it, and I don't want to unnecessarily break stuff.

Resolving patch:
diff --git a/src/streamlink/plugins/rtve.py b/src/streamlink/plugins/rtve.py
--- a/src/streamlink/plugins/rtve.py
+++ b/src/streamlink/plugins/rtve.py
@@ -12,7 +12,7 @@
 from typing import Iterator, Sequence, Tuple
 from urllib.parse import urlparse
 
-from streamlink.plugin import Plugin, pluginargument, pluginmatcher
+from streamlink.plugin import Plugin, PluginError, pluginargument, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.ffmpegmux import MuxedStream
 from streamlink.stream.hls import HLSStream
@@ -29,7 +29,7 @@ def __init__(self, data: str):
         def _iterate():
             while True:
                 chunk = stream.read(1)
-                if len(chunk) == 0:  # pragma: no cover
+                if len(chunk) == 0:
                     return
                 yield ord(chunk)
 
@@ -39,7 +39,7 @@ def read(self, num: int) -> Sequence[int]:
         res = []
         for _ in range(num):
             item = next(self._iterator, None)
-            if item is None:  # pragma: no cover
+            if item is None:
                 break
             res.append(item)
         return res
@@ -63,6 +63,14 @@ def read_chunk(self) -> Tuple[str, Sequence[int]]:
         self.skip(4)
         return chunktype, chunkdata
 
+    def __iter__(self):
+        self.skip(8)
+        while True:
+            try:
+                yield self.read_chunk()
+            except ValueError:
+                return
+
 
 class ZTNR:
     @staticmethod
@@ -106,17 +114,16 @@ def _get_source(cls, alphabet: str, data: str) -> str:
     @classmethod
     def translate(cls, data: str) -> Iterator[Tuple[str, str]]:
         reader = Base64Reader(data.replace("\n", ""))
-        reader.skip(8)
-        chunk_type, chunk_data = reader.read_chunk()
-        while chunk_type != "IEND":
+        for chunk_type, chunk_data in reader:
+            if chunk_type == "IEND":
+                break
             if chunk_type == "tEXt":
                 content = "".join(chr(item) for item in chunk_data if item > 0)
-                if "#" not in content or "%%" not in content:  # pragma: no cover
+                if "#" not in content or "%%" not in content:
                     continue
                 alphabet, content = content.split("#", 1)
                 quality, content = content.split("%%", 1)
                 yield quality, cls._get_source(alphabet, content)
-            chunk_type, chunk_data = reader.read_chunk()
 
 
 @pluginmatcher(re.compile(
@@ -147,18 +154,19 @@ def _get_streams(self):
             return
 
         # check obfuscated stream URLs via self.URL_VIDEOS and ZTNR.translate() first
-        # self.URL_M3U8 appears to be valid for all streams, but doesn't provide any content in same cases
-        urls = self.session.http.get(
-            self.URL_VIDEOS.format(id=self.id),
-            schema=validate.Schema(
-                validate.transform(ZTNR.translate),
-                validate.transform(list),
-                [(str, validate.url())],
-            ),
-        )
-
-        # then fall back to self.URL_M3U8
-        if not urls:
+        # self.URL_M3U8 appears to be valid for all streams, but doesn't provide any content in some cases
+        try:
+            urls = self.session.http.get(
+                self.URL_VIDEOS.format(id=self.id),
+                schema=validate.Schema(
+                    validate.transform(ZTNR.translate),
+                    validate.transform(list),
+                    [(str, validate.url())],
+                    validate.length(1),
+                ),
+            )
+        except PluginError:
+            # catch HTTP errors and validation errors, and fall back to generic HLS URL template
             url = self.URL_M3U8.format(id=self.id)
         else:
             url = next((url for _, url in urls if urlparse(url).path.endswith(".m3u8")), None)


Test patch:
diff --git a/tests/plugins/test_rtve.py b/tests/plugins/test_rtve.py
--- a/tests/plugins/test_rtve.py
+++ b/tests/plugins/test_rtve.py
@@ -8,7 +8,7 @@ class TestPluginCanHandleUrlRtve(PluginCanHandleUrl):
     should_match = [
         "https://www.rtve.es/play/videos/directo/la-1/",
         "https://www.rtve.es/play/videos/directo/canales-lineales/24h/",
-        "https://www.rtve.es/play/videos/rebelion-en-el-reino-salvaje/mata-reyes/5803959/",
+        "https://www.rtve.es/play/videos/informe-semanal/la-semilla-de-la-guerra/6670279/",
     ]
 
     should_not_match = [
@@ -21,7 +21,28 @@ class TestPluginCanHandleUrlRtve(PluginCanHandleUrl):
     ]
 
 
-def test_translate():
+def test_translate_no_content():
+    assert list(ZTNR.translate("")) == []
+
+
+def test_translate_no_streams():
+    # real payload without any tEXt chunks that match the expected format
+    data = \
+        "iVBORw0KGgoAAAANSUhEUgAAAsAAAAGMAQMAAADuk4YmAAAAA1BMVEX///+nxBvIAAAAAXRSTlMA" \
+        "QObYZgAAADlJREFUeF7twDEBAAAAwiD7p7bGDlgYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" \
+        "AAAAAAAAwAGJrAABgPqdWQAAAcp0RVh0ak9lNmRyNkUtV2hmeEE0dERMdS9FOTlCT2d3MF9HMDdG" \
+        "RmxQNy1ZLTdFOFRac0MmbD93VEp5SENvUUlseVY1bjdrYmF2ZkhUUjc4aTBHAEBxY08zdk4yYldE" \
+        "bm09TDVaNGMyVVpNdklVbS5LVUNCUTdZNVpfSUZMVmRMNlN0VE14TmFPLUFGaTF6ai9YenE6PVg9" \
+        "dnJBb3BFU3BBJlpoWFViSER3MCZxbj9AS0d1Si5OSnAudiMwMTYxNDA2MDU2NjcyMDE3MzI4NTcw" \
+        "ODcwMDc3MDI3NjIwNjczMTA0ODEyNDY3MzMwNzgxMDQwMTE4NzQ4MDYwMjIwODgxNTI0ODEzNjQ4" \
+        "MjU0MTEyMzEyNjUxMzc2NTM3MTMzNzgwNTYwNDE0NjI4NDM1NjIzNTA1MTAxNjYwMDExNzE4MDQx" \
+        "MTc3MDMxNTQ2MDEzNDUwMDQ2MTg4MDgwNzMxNDM3MjgwMDQ4NDA3Mzg0MzYxODA0NjU0NDYzMTY1" \
+        "NDIxMzY4ODAzNTQ3MjMyMjYzODUwMzY5MTE3MTMwOTMzMjAwNDg1MDExNTE4MTgxMTgwMTAwNjU0" \
+        "NTg1MzcxNDQ5MDM5MzY2ODMxNTc0MjUyNDVZsdrfAAAAAElFTkSuQmCC"
+    assert list(ZTNR.translate(data)) == []
+
+
+def test_translate_has_streams():
     # real payload with modified end (IEND chunk of size 0), to reduce test size
     data = \
         "iVBORw0KGgoAAAANSUhEUgAAAVQAAAFUCAIAAAD08FPiAAACr3RFWHRXczlVSWdtM2ZPTGY4b2R4" \