Repository: streamlink/streamlink

Issue:
plugins.rtpplay: Site obfuscated the download URL
<!--
Thanks for reporting a plugin issue!
USE THE TEMPLATE. Otherwise your plugin issue may be rejected.

First, see the contribution guidelines:
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#contributing-to-streamlink

Plugin issues describe broken functionality within a plugin's code base, eg. the streaming site has made breaking changes, streams don't get resolved correctly, or authentication has stopped working, etc.

Also check the list of open and closed plugin issues:
https://github.com/streamlink/streamlink/issues?q=is%3Aissue+label%3A%22plugin+issue%22

Please see the text preview to avoid unnecessary formatting errors.
-->


## Plugin Issue

<!-- Replace the space character between the square brackets with an x in order to check the boxes -->
- [x] This is a plugin issue and I have read the contribution guidelines.
- [ ] I am using the latest development version from the master branch.

### Description

The RTP site now obfuscating the URL with some function calls. *sigh*

Interpreting the JS leads to working URLs:

```javascript
var f = {hls : atob( decodeURIComponent(["aHR0cHM6Ly","9zdHJlYW1p","bmctdm9kLn","J0cC5wdC9o","bHMvbmFzMi","5zaGFyZS9o","MjY0LzUxMn","gzODQvcDg1","MDEvcDg1MD","FfMV8yMDIx","MDIxMjU1MD","YubXA0L21h","c3Rlci5tM3","U4"].join(""))), dash : atob( decodeURIComponent(["aHR0cHM","6Ly9zdH","JlYW1pb","mctdm9k","LnJ0cC5","wdC9kYX","NoL25hc","zIuc2hh","cmUvaDI","2NC81MT","J4Mzg0L","3A4NTAx","L3A4NTA","xXzFfMj","AyMTAyM","TI1NTA2","Lm1wNC9","tYW5pZm","VzdC5tc","GQ%3D"].join(""))) }
```
```
dash: "https://streaming-vod.rtp.pt/dash/nas2.share/h264/512x384/p8501/p8501_1_202102125506.mp4/manifest.mpd"
​hls: "https://streaming-vod.rtp.pt/hls/nas2.share/h264/512x384/p8501/p8501_1_202102125506.mp4/master.m3u8"
```

```javascript
      var x1 = decodeURIComponent(["https://cdn-ondemand.rtp.pt/nas2.share/h264/512x384/p5449/preview/p5449_1_201901231263_videoprv.mp4"].join(""));

      if ($("#player_prog").length > 0) {
      /*  var player1 = new RTPPlayer({
            file: {hls : atob( decodeURIComponent(["aHR0cHM","6Ly9zdH","JlYW1pb","mctb25k","ZW1hbmQ","ucnRwLn","B0L25hc","zIuc2hh","cmUvaDI","2NC81MT","J4Mzg0L","3AxNTI1","L3AxNTI","1XzFfMj","AyMTA0M","jA1OTk2","L21hc3R","lcnMubT","N1OA%3D","%3D"].join(""))), dash : atob( decodeURIComponent(["aHR0cHM6Ly","9zdHJlYW1p","bmctb25kZW","1hbmQucnRw","LnB0L25hcz","Iuc2hhcmUv","aDI2NC81MT","J4Mzg0L3Ax","NTI1L3AxNT","I1XzFfMjAy","MTA0MjA1OT","k2L21hc3Rl","cnMubXBk"].join(""))) },
                                      seekBarThumbnailsLoc: '//cdn-images.rtp.pt/multimedia/screenshots/p1525/preview/p1525_1_202104205996_preview.vtt',      }); */
        var f = {hls : atob( decodeURIComponent(["aHR0cH","M6Ly9z","dHJlYW","1pbmct","dm9kLn","J0cC5w","dC9kcm","0tZnBz","L25hcz","Iuc2hh","cmUvaD","I2NC81","MTJ4Mz","g0L3Ax","NTI1L3","AxNTI1","XzFfMj","AyMTA1","MTA2MT","E4Lm1w","NC9tYX","N0ZXIu","bTN1OA","%3D%3D"].join(""))), dash : atob( decodeURIComponent(["aHR0cH","M6Ly9z","dHJlYW","1pbmct","dm9kLn","J0cC5w","dC9kcm","0tZGFz","aC9uYX","MyLnNo","YXJlL2","gyNjQv","NTEyeD","M4NC9w","MTUyNS","9wMTUy","NV8xXz","IwMjEw","NTEwNj","ExOC5t","cDQvbW","FuaWZl","c3QubX","Bk"].join(""))) };

        var f = {hls : atob( decodeURIComponent(["aHR0cHM6Ly","9zdHJlYW1p","bmctdm9kLn","J0cC5wdC9o","bHMvbmFzMi","5zaGFyZS9o","MjY0LzUxMn","gzODQvcDg1","MDEvcDg1MD","FfMV8yMDIx","MDIxMjU1MD","YubXA0L21h","c3Rlci5tM3","U4"].join(""))), dash : atob( decodeURIComponent(["aHR0cHM","6Ly9zdH","JlYW1pb","mctdm9k","LnJ0cC5","wdC9kYX","NoL25hc","zIuc2hh","cmUvaDI","2NC81MT","J4Mzg0L","3A4NTAx","L3A4NTA","xXzFfMj","AyMTAyM","TI1NTA2","Lm1wNC9","tYW5pZm","VzdC5tc","GQ%3D"].join(""))) };
        var player1 = new RTPPlayer({
          id: "player_prog",
          fileKey: atob( decodeURIComponent(["L25hczIuc2","hhcmUvaDI2","NC81MTJ4Mz","g0L3A4NTAx","L3A4NTAxXz","FfMjAyMTAy","MTI1NTA2Lm","1wNA%3D%3D"].join(""))),
          file: f,
                                  seekBarThumbnailsLoc: '//cdn-images.rtp.pt/multimedia/screenshots/p8501/preview/p8501_1_202102125506_preview.vtt',
                      adsType: 'dfp',
          subadUnit: 'Play',
          streamType:"ondemand",
          googleCast: true,
          drm : false,          //extraSettings: { googleCastReceiverAppId:'1A6F2224', skin:'s3',  skinAccentColor: '0073FF'},
          extraSettings: { googleCastReceiverAppId:'1A6F2224'},
          mediaType: "video",
          poster: "https://cdn-images.rtp.pt/EPG/imagens/26039_34268_59543.jpg?v=3&w=1170",
          modules: [{ name: 'RTPFavIcon', bucket: '1' }],
          metadata: {
            id: "904282"
          }
        });

        var userPreferences = RTPPLAY.parentalProtection.loadActivatedUserServices(['continue', 'favorite']);

        var startPlayer = function() {
                    
            if (userPreferences.continue.isActive){
              TelemetryRTPPlay.start({player_id:"player_prog",
                player_obj: player1,
                telemetry_name: "RTPPlay_telemetry_",
                key: "524190",
                asset_id : "904282",
                content_type : "video",
                serverTime : 1621633943000,
                telemetry_obj : {asset_id : "904282",
                content_type : "video",
                content_title : "Cidade Despida",
                content_episode : "1",
                content_date: "2021-01-12",
                content_img : "https://cdn-images.rtp.pt/EPG/imagens/26039_34268_59543.jpg?v=3&w=300",
                content_url : "https://www.rtp.pt/play/p8501/e524190/cidade-despida",
                content_episode_part: "0",
                content_episode_lifetime : "2999-12-31",
                content_duration : "2830",
                content_duration_complete : "2829" ,
                content_duration_before : "" }
              });
            }
```
### Reproduction steps / Explicit stream URLs to test

<!-- How can we reproduce this? Please note the exact steps below using the list format supplied. If you need more steps please add them. -->

1. ...
2. ...
3. ...


### Log output
```
$  streamlink -l trace https://www.rtp.pt/play/p8501/cidade-despida --output s1e1.mp4
[22:53:38.592806][cli][debug] OS:         Linux-5.12.4-arch1-2-x86_64-with-glibc2.33
[22:53:38.593056][cli][debug] Python:     3.9.5
[22:53:38.593124][cli][debug] Streamlink: 2.1.1
[22:53:38.593220][cli][debug] Requests(2.25.1), Socks(1.7.1), Websocket(0.59.0)
[22:53:38.594135][cli][debug] Arguments:
[22:53:38.594264][cli][debug]  url=https://www.rtp.pt/play/p8501/cidade-despida
[22:53:38.594370][cli][debug]  --loglevel=trace
[22:53:38.594465][cli][debug]  --output=s1e1.mp4
[22:53:38.594725][cli][info] Found matching plugin rtpplay for URL https://www.rtp.pt/play/p8501/cidade-despida
error: No playable streams found on this URL: https://www.rtp.pt/play/p8501/cidade-despida
```


### Additional comments, etc.

[Love Streamlink? Please consider supporting our collective. Thanks!](https://opencollective.com/streamlink/donate)

Discussion:
gravyboat: Looks good, thanks @bastimeyer.

Patch:
diff --git a/src/streamlink/plugins/rtpplay.py b/src/streamlink/plugins/rtpplay.py
index 616faef5..b5dc45d7 100644
--- a/src/streamlink/plugins/rtpplay.py
+++ b/src/streamlink/plugins/rtpplay.py
@@ -1,4 +1,5 @@
 import re
+from base64 import b64decode
 from urllib.parse import unquote
 
 from streamlink.plugin import Plugin
@@ -10,22 +11,38 @@ from streamlink.utils import parse_json
 class RTPPlay(Plugin):
     _url_re = re.compile(r"https?://www\.rtp\.pt/play/")
     _m3u8_re = re.compile(r"""
-        hls:\s*(?:(["'])(?P<string>[^"']+)\1
-        |
-        decodeURIComponent\((?P<obfuscated>\[.*?])\.join\()
+        hls\s*:\s*(?:
+            (["'])(?P<string>[^"']*)\1
+            |
+            decodeURIComponent\s*\((?P<obfuscated>\[.*?])\.join\(
+            |
+            atob\s*\(\s*decodeURIComponent\s*\((?P<obfuscated_b64>\[.*?])\.join\(
+        )
     """, re.VERBOSE)
 
     _schema_hls = validate.Schema(
-        validate.transform(_m3u8_re.search),
+        validate.transform(lambda text: next(reversed(list(RTPPlay._m3u8_re.finditer(text))), None)),
         validate.any(
             None,
             validate.all(
                 validate.get("string"),
-                validate.url()
+                str,
+                validate.any(
+                    validate.length(0),
+                    validate.url()
+                )
             ),
             validate.all(
                 validate.get("obfuscated"),
+                str,
+                validate.transform(lambda arr: unquote("".join(parse_json(arr)))),
+                validate.url()
+            ),
+            validate.all(
+                validate.get("obfuscated_b64"),
+                str,
                 validate.transform(lambda arr: unquote("".join(parse_json(arr)))),
+                validate.transform(lambda b64: b64decode(b64).decode("utf-8")),
                 validate.url()
             )
         )
@@ -39,9 +56,8 @@ class RTPPlay(Plugin):
         self.session.http.headers.update({"User-Agent": useragents.CHROME,
                                           "Referer": self.url})
         hls_url = self.session.http.get(self.url, schema=self._schema_hls)
-        if not hls_url:
-            return
-        return HLSStream.parse_variant_playlist(self.session, hls_url)
+        if hls_url:
+            return HLSStream.parse_variant_playlist(self.session, hls_url)
 
 
 __plugin__ = RTPPlay


Test patch:
diff --git a/tests/plugins/test_rtpplay.py b/tests/plugins/test_rtpplay.py
index 372b3fe7..992e291c 100644
--- a/tests/plugins/test_rtpplay.py
+++ b/tests/plugins/test_rtpplay.py
@@ -1,5 +1,12 @@
+import unittest
+
+import requests_mock
+
+from streamlink import Streamlink
 from streamlink.plugins.rtpplay import RTPPlay
+from streamlink.stream import HLSStream
 from tests.plugins import PluginCanHandleUrl
+from tests.resources import text
 
 
 class TestPluginCanHandleUrlRTPPlay(PluginCanHandleUrl):
@@ -18,3 +25,54 @@ class TestPluginCanHandleUrlRTPPlay(PluginCanHandleUrl):
         'https://media.rtp.pt/',
         'http://media.rtp.pt/',
     ]
+
+
+class TestRTPPlay(unittest.TestCase):
+    # all invalid HLS URLs at the beginning need to be ignored ("https://invalid")
+    _content_pre = """
+        /*  var player1 = new RTPPlayer({
+                file: {hls : atob( decodeURIComponent(["aHR0c", "HM6Ly", "9pbnZ", "hbGlk"].join("") ) ), dash : foo() } }); */
+        var f = {hls : atob( decodeURIComponent(["aHR0c", "HM6Ly", "9pbnZ", "hbGlk"].join("") ) ), dash: foo() };
+    """
+    # invalid resources sometimes have an empty string as HLS URL
+    _content_invalid = """
+        var f = {hls : ""};
+    """
+    # the actual HLS URL always comes last ("https://valid")
+    _content_valid = """
+        var f = {hls : decodeURIComponent(["https%3","A%2F%2F", "valid"].join("") ), dash: foo() };
+    """
+    _content_valid_b64 = """
+        var f = {hls : atob( decodeURIComponent(["aHR0c", "HM6Ly", "92YWx", "pZA=="].join("") ) ), dash: foo() };
+    """
+
+    @property
+    def playlist(self):
+        with text("hls/test_master.m3u8") as pl:
+            return pl.read()
+
+    def subject(self, url, response):
+        with requests_mock.Mocker() as mock:
+            mock.get(url=url, text=response)
+            mock.get("https://valid", text=self.playlist)
+            mock.get("https://invalid", exc=AssertionError)
+            session = Streamlink()
+            RTPPlay.bind(session, "tests.plugins.test_rtpplay")
+            plugin = RTPPlay(url)
+            return plugin._get_streams()
+
+    def test_empty(self):
+        streams = self.subject("https://www.rtp.pt/play/id/title", "")
+        self.assertEqual(streams, None)
+
+    def test_invalid(self):
+        streams = self.subject("https://www.rtp.pt/play/id/title", self._content_pre + self._content_invalid)
+        self.assertEqual(streams, None)
+
+    def test_valid(self):
+        streams = self.subject("https://www.rtp.pt/play/id/title", self._content_pre + self._content_valid)
+        self.assertIsInstance(next(iter(streams.values())), HLSStream)
+
+    def test_valid_b64(self):
+        streams = self.subject("https://www.rtp.pt/play/id/title", self._content_pre + self._content_valid_b64)
+        self.assertIsInstance(next(iter(streams.values())), HLSStream)