Repository: streamlink/streamlink

Issue:
plugins.raiplay: Malformed HLS Playlist
### 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 build from the master branch

### Description

Plugin stopped working.

### Debug log

```text
streamlink https://www.raiplay.it/dirette/rai1 best
[cli][info] Found matching plugin raiplay for URL https://www.raiplay.it/dirette/rai1
[stream.hls_playlist][warning] Malformed HLS Playlist. Expected #EXTM3U, but got <Mediapolis>
error: Failed to parse playlist: Missing #EXTM3U header
```



Discussion:
Post the entire **debug log**, as requested by the plugin issue form.

Thesite is geo-blocked and it also blocks popular VPN services, so I can't access its streams.

The plugin requests the correct API endpoint and then tries to access the resolved stream URL, which appears to be
`https://mediapolis.rai.it/relinker/relinkerServlet.htm?cont=2606803`
for the `rai1` channel.

https://github.com/streamlink/streamlink/blob/4.3.0/src/streamlink/plugins/raiplay.py#L39-L45

If it's geo-blocked, then the returned content is not an HLS playlist, but a video instead, which the plugin handles correctly.
```
$ curl -sIL "https://mediapolis.rai.it/relinker/relinkerServlet.htm?cont=2606803"
HTTP/1.1 302 Moved Temporarily
Date: Sat, 10 Sep 2022 14:37:54 GMT
Server: Apache
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, OPTIONS, HEAD
Vary: X-Forwarded-Proto
Location: https://download-rai-it.akamaized.net/video_no_available.mp4
Content-Type: text/html

HTTP/1.1 200 OK
Accept-Ranges: bytes
ETag: "51a83016a9c866c8213e52cdbcd19d3d:1415293655"
Last-Modified: Thu, 06 Nov 2014 17:07:35 GMT
Server: AkamaiNetStorage
Content-Length: 1687158
Cache-Control: max-age=29673477
Date: Sat, 10 Sep 2022 14:37:55 GMT
Connection: keep-alive
Akamai-Mon-Iucid-Del: 631717
Alt-Svc: h3-Q050=":443"; ma=93600,quic=":443"; ma=93600; v="46,43"
Content-Type: video/mp4
Access-Control-Max-Age: 86400
Access-Control-Allow-Credentials: true
Access-Control-Expose-Headers: Server,range,hdntl,hdnts,Akamai-Mon-Iucid-Ing,Akamai-Mon-Iucid-Del
Access-Control-Allow-Headers: origin,range,hdntl,hdnts
Access-Control-Allow-Methods: GET,POST,OPTIONS
Access-Control-Allow-Origin: *
```

**Please post the output of the `curl` command shown above.** (in addition to the debug log mentioned above)
> `Expected #EXTM3U, but got <Mediapolis>`

Btw, this looks like there's some XML or another custom HTTP response instead of the HLS playlist content. Either way, I can't access it, and I've checked several different VPN services.
Here are the debug logs for rai1, rai2 and the curl:

```text
streamlink -l debug https://www.raiplay.it/dirette/rai1 best
[cli][info] Found matching plugin raiplay for URL https://www.raiplay.it/dirette/rai1
[plugins.raiplay][debug] Found JSON URL: https://www.raiplay.it/dirette/rai1.json
[plugins.raiplay][debug] Found stream URL: https://mediapolis.rai.it/relinker/relinkerServlet.htm?cont=2606803
[utils.l10n][debug] Language code: en_US
[stream.hls_playlist][warning] Malformed HLS Playlist. Expected #EXTM3U, but got <Mediapolis>
error: Failed to parse playlist: Missing #EXTM3U header
```

```text
streamlink -l debug https://www.raiplay.it/dirette/rai2 best
[cli][info] Found matching plugin raiplay for URL https://www.raiplay.it/dirette/rai2
[plugins.raiplay][debug] Found JSON URL: https://www.raiplay.it/dirette/rai2.json
[plugins.raiplay][debug] Found stream URL: https://mediapolis.rai.it/relinker/relinkerServlet.htm?cont=308718
[utils.l10n][debug] Language code: en_US
[stream.hls_playlist][warning] Malformed HLS Playlist. Expected #EXTM3U, but got <Mediapolis>
error: Failed to parse playlist: Missing #EXTM3U header
```

```text
curl -sIL "https://mediapolis.rai.it/relinker/relinkerServlet.htm?cont=2606803"
HTTP/1.1 200 OK

HTTP/1.1 200 OK
Date: Sat, 10 Sep 2022 19:05:11 GMT
Server: Apache
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: GET, OPTIONS, HEAD
Vary: X-Forwarded-Proto
X-Powered-By: JSP/2.2
Set-Cookie: JSESSIONID=vIbyMqx1bQc4ofepIrde1voL; Path=/relinker
Cache-Control: private
Expires: Sat, 10 Sep 2022 19:05:31 GMT
Content-Language: it-IT
Content-Length: 27
Content-Type: text/xml
```
If you want, you can post the XML output of
```
$ curl -sL "https://mediapolis.rai.it/relinker/relinkerServlet.htm?cont=2606803"
```
but I don't think that debugging it this way makes sense. As said, the result can be anything, but if I have to ask someone else for any server responses, then this is just stupid. Fixing and potentially re-implementing the plugin requires being able to see and understand all the HTTP responses.
Rai News24 is normally not geo blocked, if that is of any help.
https://www.raiplay.it/dirette/rainews24



Resolving patch:
diff --git a/src/streamlink/plugins/raiplay.py b/src/streamlink/plugins/raiplay.py
--- a/src/streamlink/plugins/raiplay.py
+++ b/src/streamlink/plugins/raiplay.py
@@ -7,11 +7,12 @@
 
 import logging
 import re
-from urllib.parse import urlparse, urlunparse
+from urllib.parse import parse_qsl, urlparse, urlunparse
 
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.hls import HLSStream
+from streamlink.utils.url import update_qsd
 
 log = logging.getLogger(__name__)
 
@@ -20,6 +21,8 @@
     r"https?://(?:www\.)?raiplay\.it/dirette/(\w+)/?"
 ))
 class RaiPlay(Plugin):
+    _DEFAULT_MEDIAPOLIS_OUTPUT = "64"
+
     def _get_streams(self):
         json_url = self.session.http.get(self.url, schema=validate.Schema(
             validate.parse_html(),
@@ -31,19 +34,44 @@ def _get_streams(self):
         json_url = urlunparse(urlparse(self.url)._replace(path=json_url))
         log.debug(f"Found JSON URL: {json_url}")
 
-        stream_url = self.session.http.get(json_url, schema=validate.Schema(
+        content_url = self.session.http.get(json_url, schema=validate.Schema(
             validate.parse_json(),
             {"video": {"content_url": validate.url()}},
             validate.get(("video", "content_url")),
         ))
-        log.debug(f"Found stream URL: {stream_url}")
+        if not content_url:
+            log.error("Missing content URL")
+            return
+
+        content_url = content_url.replace("/relinkerServlet.mp4", "/relinkerServlet.htm")
+        parsed = urlparse(content_url)
+        params = dict(parse_qsl(parsed.query))
+        if not parsed.path.endswith(".xml") and not params.get("output"):
+            params["output"] = self._DEFAULT_MEDIAPOLIS_OUTPUT
+            content_url = update_qsd(urlunparse(parsed), params)
 
-        res = self.session.http.request("HEAD", stream_url)
+        res = self.session.http.head(content_url)
         # status code will be 200 even if geo-blocked, so check the returned content-type
         if not res or not res.headers or res.headers["Content-Type"] == "video/mp4":
             log.error("Geo-restricted content")
             return
 
+        stream_url = self.session.http.get(
+            content_url,
+            schema=validate.Schema(
+                validate.parse_xml(),
+                validate.xml_element(tag="Mediapolis"),
+                validate.xml_xpath_string("./url[@type='content']/text()"),
+                validate.none_or_all(
+                    validate.transform(str.strip),
+                    validate.url(path=validate.endswith(".m3u8")),
+                ),
+            ),
+        )
+        if not stream_url:
+            log.error("Missing stream URL")
+            return
+
         yield from HLSStream.parse_variant_playlist(self.session, stream_url).items()
 
 


Test patch: