Repository: streamlink/streamlink

Issue:
plugins.rtve: live streams broken
### 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

Since 22 June 2022 the "live" channels have stoped working.   vod works perfect
Example
streamlink --stdout --default-stream best --url  https://www.rtve.es/play/videos/directo/canales-lineales/la-1
Error message:
[cli][info] Found matching plugin rtve for URL https://www.rtve.es/play/videos/directo/canales-lineales/la-1
error: No playable streams found on this URL: https://www.rtve.es/play/videos/directo/canales-lineales/la-1


### Debug log

```text
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.10.5
[cli][debug] Streamlink: 4.1.0
[cli][debug] Requests(2.27.1), Socks(1.7.1), Websocket(1.3.2)
[cli][debug] Arguments:
[cli][debug]  url=https://www.rtve.es/play/videos/directo/canales-lineales/la-1
[cli][debug]  --loglevel=debug
[cli][debug]  --stdout=True
[cli][debug]  --url=https://www.rtve.es/play/videos/directo/canales-lineales/la-1
[cli][debug]  --default-stream=['best']
[cli][debug]  --ffmpeg-ffmpeg=C:\Program Files\Streamlink\ffmpeg\ffmpeg.exe
[cli][info] Found matching plugin rtve for URL https://www.rtve.es/play/videos/directo/canales-lineales/la-1
[plugins.rtve][debug] Found content with id: 1688877
error: No playable streams found on this URL: https://www.rtve.es/play/videos/directo/canales-lineales/la-1
```



Discussion:
Fixing the live streams is pretty simple. I have already done that on my local branch. I haven't had a closer look at the VODs yet though. There seem to be two kinds of VODs, regular short videos and movies with subtitles, but they all appear to be working via the regular HLS stream URL format the live streams are using.

The plugin currently implements lots of stuff which seems to be pointless now (ZTNRClient, CDN stuff, video API), but as said, I haven't had a closer look yet at the other kind of content.
https://github.com/streamlink/streamlink/blob/cc455586c55ea9bdbcd3a4c58f62b9bf9a0dddd0/src/streamlink/plugins/rtve.py
Great, thanks, i will wait until the modification is upload to the main branch. Since the link provided only links the original one and not the modified. Thanks again.
I had another look at this.

As said, updating the live streams is fairly easy.

The VODs and movies however are accessed differently now via their website: obfuscated HLS URLs are embedded in PNG files with custom base64 alphabets. The HLS URL obfuscation requires re-implementing lots of JS code in Python, from the `rtve/ztnrThumbnail` module of this JS file:
https://js2.rtve.es/player/pf_video.js

The old API which the plugin is currently using for getting VOD HTTP URLs seems to be still working for whatever reason, but it's likely that it won't stay available for long.

I will submit a PR for updating the live streams later today or tomorrow. It's not worth the time implementing the de-obfuscation of VOD HLS URLs. Also no idea about the available movies and their subtitles.

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
@@ -5,171 +5,190 @@
 $region Spain
 """
 
-import base64
 import logging
 import re
+from base64 import b64decode
+from io import BytesIO
+from typing import Iterator, Sequence, Tuple
 from urllib.parse import urlparse
 
-from Crypto.Cipher import Blowfish
-
 from streamlink.plugin import Plugin, PluginArgument, PluginArguments, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.ffmpegmux import MuxedStream
 from streamlink.stream.hls import HLSStream
 from streamlink.stream.http import HTTPStream
+from streamlink.utils.url import update_scheme
 
 log = logging.getLogger(__name__)
 
 
-class ZTNRClient:
-    base_url = "https://ztnr.rtve.es/ztnr/res/"
-    block_size = 16
+class Base64Reader:
+    def __init__(self, data: str):
+        stream = BytesIO(b64decode(data))
 
-    def __init__(self, key, session):
-        self.cipher = Blowfish.new(key, Blowfish.MODE_ECB)
-        self.session = session
+        def _iterate():
+            while True:
+                chunk = stream.read(1)
+                if len(chunk) == 0:  # pragma: no cover
+                    return
+                yield ord(chunk)
 
-    @classmethod
-    def pad(cls, data):
-        n = cls.block_size - len(data) % cls.block_size
-        return data + bytes(chr(cls.block_size - len(data) % cls.block_size), "utf8") * n
+        self._iterator: Iterator[int] = _iterate()
 
-    @staticmethod
-    def unpad(data):
-        return data[0:-data[-1]]
+    def read(self, num: int) -> Sequence[int]:
+        res = []
+        for _ in range(num):
+            item = next(self._iterator, None)
+            if item is None:  # pragma: no cover
+                break
+            res.append(item)
+        return res
+
+    def skip(self, num: int) -> None:
+        self.read(num)
+
+    def read_chars(self, num: int) -> str:
+        return "".join(chr(item) for item in self.read(num))
 
-    def encrypt(self, data):
-        return base64.b64encode(self.cipher.encrypt(self.pad(bytes(data, "utf-8"))), altchars=b"-_").decode("ascii")
+    def read_int(self) -> int:
+        a, b, c, d = self.read(4)
+        return a << 24 | b << 16 | c << 8 | d
 
-    def decrypt(self, data):
-        return self.unpad(self.cipher.decrypt(base64.b64decode(data, altchars=b"-_")))
+    def read_chunk(self) -> Tuple[str, Sequence[int]]:
+        size = self.read_int()
+        chunktype = self.read_chars(4)
+        chunkdata = self.read(size)
+        if len(chunkdata) != size:  # pragma: no cover
+            raise ValueError("Invalid chunk length")
+        self.skip(4)
+        return chunktype, chunkdata
 
-    def request(self, data, *args, **kwargs):
-        res = self.session.http.get(self.base_url + self.encrypt(data), *args, **kwargs)
-        return self.decrypt(res.content)
 
-    def get_cdn_list(self, vid, manager="apedemak", vtype="video", lang="es", schema=None):
-        data = self.request("{id}_{manager}_{type}_{lang}".format(id=vid, manager=manager, type=vtype, lang=lang))
-        if schema:
-            return schema.validate(data)
-        else:
-            return data
+class ZTNR:
+    @staticmethod
+    def _get_alphabet(text: str) -> str:
+        res = []
+        j = 0
+        k = 0
+        for char in text:
+            if k > 0:
+                k -= 1
+            else:
+                res.append(char)
+                j = (j + 1) % 4
+                k = j
+        return "".join(res)
+
+    @staticmethod
+    def _get_url(text: str, alphabet: str) -> str:
+        res = []
+        j = 0
+        n = 0
+        k = 3
+        cont = 0
+        for char in text:
+            if j == 0:
+                n = int(char) * 10
+                j = 1
+            elif k > 0:
+                k -= 1
+            else:
+                res.append(alphabet[n + int(char)])
+                j = 0
+                k = cont % 4
+                cont += 1
+        return "".join(res)
+
+    @classmethod
+    def _get_source(cls, alphabet: str, data: str) -> str:
+        return cls._get_url(data, cls._get_alphabet(alphabet))
+
+    @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":
+            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
+                    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(
     r"https?://(?:www\.)?rtve\.es/play/videos/.+"
 ))
 class Rtve(Plugin):
-    _re_idAsset = re.compile(r"\"idAsset\":\"(\d+)\"")
-    secret_key = base64.b64decode("eWVMJmRhRDM=")
-    cdn_schema = validate.Schema(
-        validate.parse_xml(invalid_char_entities=True),
-        validate.xml_findall(".//preset"),
-        [
-            validate.union({
-                "quality": validate.all(validate.getattr("attrib"),
-                                        validate.get("type")),
-                "urls": validate.all(
-                    validate.xml_findall(".//url"),
-                    [validate.getattr("text")]
-                )
-            })
-        ]
-    )
-    subtitles_api = "https://www.rtve.es/api/videos/{id}/subtitulos.json"
-    subtitles_schema = validate.Schema({
-        "page": {
-            "items": [{
-                "src": validate.url(),
-                "lang": validate.text
-            }]
-        }
-    },
-        validate.get("page"),
-        validate.get("items"))
-    video_api = "https://www.rtve.es/api/videos/{id}.json"
-    video_schema = validate.Schema({
-        "page": {
-            "items": [{
-                "qualities": [{
-                    "preset": validate.text,
-                    "height": int
-                }]
-            }]
-        }
-    },
-        validate.get("page"),
-        validate.get("items"),
-        validate.get(0))
-
     arguments = PluginArguments(
-        PluginArgument("mux-subtitles", is_global=True)
+        PluginArgument("mux-subtitles", is_global=True),
     )
 
-    def __init__(self, url):
-        super().__init__(url)
-        self.zclient = ZTNRClient(self.secret_key, self.session)
-
-    def _get_subtitles(self, content_id):
-        res = self.session.http.get(self.subtitles_api.format(id=content_id))
-        return self.session.http.json(res, schema=self.subtitles_schema)
-
-    def _get_quality_map(self, content_id):
-        res = self.session.http.get(self.video_api.format(id=content_id))
-        data = self.session.http.json(res, schema=self.video_schema)
-        qmap = {}
-        for item in data["qualities"]:
-            qname = {"MED": "Media", "HIGH": "Alta", "ORIGINAL": "Original"}.get(item["preset"], item["preset"])
-            qmap[qname] = f"{item['height']}p"
-        return qmap
+    URL_VIDEOS = "https://ztnr.rtve.es/ztnr/movil/thumbnail/rtveplayw/videos/{id}.png?q=v2"
+    URL_SUBTITLES = "https://www.rtve.es/api/videos/{id}/subtitulos.json"
 
     def _get_streams(self):
-        res = self.session.http.get(self.url)
-        m = self._re_idAsset.search(res.text)
-        if m:
-            content_id = m.group(1)
-            log.debug(f"Found content with id: {content_id}")
-            stream_data = self.zclient.get_cdn_list(content_id, schema=self.cdn_schema)
-            quality_map = None
-
-            streams = []
-            for stream in stream_data:
-                # only use one stream
-                _one_m3u8 = False
-                _one_mp4 = False
-                for url in stream["urls"]:
-                    p_url = urlparse(url)
-                    if p_url.path.endswith(".m3u8"):
-                        if _one_m3u8:
-                            continue
-                        try:
-                            streams.extend(HLSStream.parse_variant_playlist(self.session, url).items())
-                            _one_m3u8 = True
-                        except OSError as err:
-                            log.error(str(err))
-                    elif p_url.path.endswith(".mp4"):
-                        if _one_mp4:
-                            continue
-                        if quality_map is None:  # only make the request when it is necessary
-                            quality_map = self._get_quality_map(content_id)
-                        # rename the HTTP sources to match the HLS sources
-                        quality = quality_map.get(stream["quality"], stream["quality"])
-                        streams.append((quality, HTTPStream(self.session, url)))
-                        _one_mp4 = True
-
-            subtitles = None
-            if self.get_option("mux_subtitles"):
-                subtitles = self._get_subtitles(content_id)
-            if subtitles:
-                substreams = {}
-                for i, subtitle in enumerate(subtitles):
-                    substreams[subtitle["lang"]] = HTTPStream(self.session, subtitle["src"])
-
-                for q, s in streams:
-                    yield q, MuxedStream(self.session, s, subtitles=substreams)
-            else:
-                for s in streams:
-                    yield s
+        self.id = self.session.http.get(self.url, schema=validate.Schema(
+            validate.transform(re.compile(r"\bdata-setup='({.+?})'", re.DOTALL).search),
+            validate.any(None, validate.all(
+                validate.get(1),
+                validate.parse_json(),
+                {
+                    "idAsset": validate.any(int, validate.all(str, validate.transform(int))),
+                },
+                validate.get("idAsset")
+            )),
+        ))
+        if not self.id:
+            return
+
+        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())],
+            ),
+        )
+
+        url = next((url for _, url in urls if urlparse(url).path.endswith(".m3u8")), None)
+        if not url:
+            url = next((url for _, url in urls if urlparse(url).path.endswith(".mp4")), None)
+            if url:
+                yield "vod", HTTPStream(self.session, url)
+            return
+
+        streams = HLSStream.parse_variant_playlist(self.session, url).items()
+
+        if self.options.get("mux-subtitles"):
+            subs = self.session.http.get(
+                self.URL_SUBTITLES.format(id=self.id),
+                schema=validate.Schema(
+                    validate.parse_json(),
+                    {
+                        "page": {
+                            "items": [{
+                                "lang": str,
+                                "src": validate.url(),
+                            }]
+                        }
+                    },
+                    validate.get(("page", "items")),
+                ),
+            )
+            if subs:
+                subtitles = {
+                    s["lang"]: HTTPStream(self.session, update_scheme("https://", s["src"], force=True))
+                    for s in subs
+                }
+                for quality, stream in streams:
+                    yield quality, MuxedStream(self.session, stream, subtitles=subtitles)
+                return
+
+        yield from streams
 
 
 __plugin__ = Rtve


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
@@ -1,4 +1,4 @@
-from streamlink.plugins.rtve import Rtve
+from streamlink.plugins.rtve import Rtve, ZTNR
 from tests.plugins import PluginCanHandleUrl
 
 
@@ -6,16 +6,42 @@ class TestPluginCanHandleUrlRtve(PluginCanHandleUrl):
     __plugin__ = Rtve
 
     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/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/",
     ]
 
     should_not_match = [
-        'https://www.rtve.es',
-        'http://www.rtve.es/directo/la-1',
-        'http://www.rtve.es/directo/la-2/',
-        'http://www.rtve.es/directo/teledeporte/',
-        'http://www.rtve.es/directo/canal-24h/',
-        'http://www.rtve.es/infantil/directo/',
+        "https://www.rtve.es",
+        "http://www.rtve.es/directo/la-1",
+        "http://www.rtve.es/directo/la-2/",
+        "http://www.rtve.es/directo/teledeporte/",
+        "http://www.rtve.es/directo/canal-24h/",
+        "http://www.rtve.es/infantil/directo/",
+    ]
+
+
+def test_translate():
+    # real payload with modified end (IEND chunk of size 0), to reduce test size
+    data = \
+        "iVBORw0KGgoAAAANSUhEUgAAAVQAAAFUCAIAAAD08FPiAAACr3RFWHRXczlVSWdtM2ZPTGY4b2R4" \
+        "dWo5aHZnRlRhOndvZEtxN3pLOG5oNGRpbT1vREBTWHhOMGtzUVomNndAWkV5cz1GOUlCSiYxdDcy" \
+        "QmdDOFM2NGFVJmh1Nzk2bUpwOFVJOE1DJlpAY2lzdGcmbEUmRE5DZFV4SHpEOFgvLmppZ1l4b3M1" \
+        "QU1lOnl3ZS04VlBwQkZvLlFMUWZHTy1vQjNVeHhfVDF1JkRSQTpPP2J4Wm0zbFlxS3IjAEhEX1JF" \
+        "QURZJSUwNTYwNzI4Mjg4MzUyNjQyMzUxMTA0Mzg0NzI4NzY4NDEyODAzODU0ODMwMDQ3NzcwNDEx" \
+        "MDAyODE1MzM3NDU3ODAxMDg3MjgxNTg1MzMzNDE3MTYxMTE4NzQ1MTU3MjYxOTUwNzI4NzEyNDgw" \
+        "MzI4NTM1ODM1ODU3MzQyNzE0NjcyODE2NTgzNDI4NTE0NTg1MzIwMzgxODU3NDY0NzUwODI3OTQ0" \
+        "ODg3NjEzMTUzNDMxMTUxNzYzNDU1NzE0MDA1MDUzNDIxODE0ODYyNDIzODM2MTczMzQ0NjAwNTIw" \
+        "NTU2NDYyNDgxODYzNDA2MzA4MTE0ODUxMTQ2Mzg2MzYyMjQ4Mjc3MjIyMjUzNjMxMjI1MjEzMTU0" \
+        "NjI1NjIyMjM3MTA4NjEwNjI0NTYyNTMxNTA2ODEyMjQ2MzYzNzE0MzY4MDU1MTgxNTQ2NTU3MTMx" \
+        "NTI0NzU4MTU2NjAxMjY0MjA1MDU2MzcwMDM3NzcwMjA0MTYxMzE3MjQxMTI2NzYzMzUyNjY3NTQ1" \
+        "NTA1MTUxNTc2NTEzMTUwNjcxNDcyMDI2MTQyMjczNTI4NzExNjA4NTU3NjIzMzMxMzU0NDM1Mzgw" \
+        "MTI0MTQzMTU1MTMyNzc4ODI1MjcyMjUwMjY4MzYyMDUzMjQzNjA0MTYyMzkhB8fSAAAAAElFTkQAAAAACg=="
+
+    assert list(ZTNR.translate(data)) == [
+        (
+            "HD_READY",
+            "https://rtvehlsvodlote7modo2.rtve.es/mediavodv2/resources/TE_NGVA/mp4/5/3/1656573649835.mp4/video.m3u8"
+            + "?hls_no_audio_only=true&idasset=6638770"
+        ),
     ]