Repository: streamlink/streamlink

Issue:
plugins.bilibili: Playable streams can't be found on stream pages with custom layout
### Checklist

- [X] This is a [plugin issue](https://streamlink.github.io/plugins.html) and not [a different kind of issue](https://github.com/streamlink/streamlink/issues/new/choose)
- [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

streamlink 6.5.0

### Description

It appears that for Bilibili streams with custom pages, the plugin will simply say there's no playable stream found, even if a stream is actually live. An example of [a stream with a custom page at the time of posting](https://live.bilibili.com/27888667) is given in the debug log (screenshot shown here).
![image](https://github.com/streamlink/streamlink/assets/37262651/6275fb0c-e7b8-4d86-9fc7-f817536f96d1)


### Debug log

```text
PS C:\Users\Admin> streamlink --loglevel=debug https://live.bilibili.com/27888667 best
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.11.7
[cli][debug] OpenSSL:    OpenSSL 3.0.11 19 Sep 2023
[cli][debug] Streamlink: 6.5.0
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.11.17
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.3
[cli][debug]  pycountry: 23.12.11
[cli][debug]  pycryptodome: 3.19.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.23.2
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  typing-extensions: 4.9.0
[cli][debug]  urllib3: 2.1.0
[cli][debug]  websocket-client: 1.7.0
[cli][debug] Arguments:
[cli][debug]  url=https://live.bilibili.com/27888667
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --ffmpeg-ffmpeg=C:\Program Files\Streamlink\ffmpeg\ffmpeg.exe
[cli][info] Found matching plugin bilibili for URL https://live.bilibili.com/27888667
error: No playable streams found on this URL: https://live.bilibili.com/27888667
```



Discussion:

Resolving patch:
diff --git a/src/streamlink/plugins/bilibili.py b/src/streamlink/plugins/bilibili.py
--- a/src/streamlink/plugins/bilibili.py
+++ b/src/streamlink/plugins/bilibili.py
@@ -4,23 +4,31 @@
 $type live
 """
 
+import logging
 import re
 
+from streamlink.exceptions import NoStreamsError
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
 from streamlink.stream.hls import HLSStream
 
 
+log = logging.getLogger(__name__)
+
+
 @pluginmatcher(re.compile(
     r"https?://live\.bilibili\.com/(?P<channel>[^/]+)",
 ))
 class Bilibili(Plugin):
+    _URL_API_PLAYINFO = "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo"
+
     SHOW_STATUS_OFFLINE = 0
     SHOW_STATUS_ONLINE = 1
     SHOW_STATUS_ROUND = 2
 
-    def _get_streams(self):
-        schema_stream = validate.all(
+    @staticmethod
+    def _schema_streams():
+        return validate.all(
             [{
                 "protocol_name": str,
                 "format": validate.all(
@@ -44,6 +52,38 @@ def _get_streams(self):
             validate.filter(lambda item: item["protocol_name"] == "http_hls"),
         )
 
+    def _get_api_playinfo(self, room_id):
+        return self.session.http.get(
+            self._URL_API_PLAYINFO,
+            params={
+                "room_id": room_id,
+                "no_playurl": 0,
+                "mask": 1,
+                "qn": 0,
+                "platform": "web",
+                "protocol": "0,1",
+                "format": "0,1,2",
+                "codec": "0,1,2",
+                "dolby": 5,
+                "panorama": 1,
+            },
+            schema=validate.Schema(
+                validate.parse_json(),
+                {
+                    "code": 0,
+                    "data": {
+                        "playurl_info": {
+                            "playurl": {
+                                "stream": self._schema_streams(),
+                            },
+                        },
+                    },
+                },
+                validate.get(("data", "playurl_info", "playurl", "stream")),
+            ),
+        )
+
+    def _get_page_playinfo(self):
         data = self.session.http.get(
             self.url,
             schema=validate.Schema(
@@ -58,7 +98,7 @@ def _get_streams(self):
                                 "live_status": int,
                                 "playurl_info": {
                                     "playurl": {
-                                        "stream": schema_stream,
+                                        "stream": self._schema_streams(),
                                     },
                                 },
                             },
@@ -94,9 +134,18 @@ def _get_streams(self):
 
         self.id, self.author, self.category, self.title, live_status, streams = data
         if live_status != self.SHOW_STATUS_ONLINE:
-            return
+            log.info("Channel is offline")
+            raise NoStreamsError
+
+        return streams
+
+    def _get_streams(self):
+        streams = self._get_page_playinfo()
+        if not streams:
+            log.debug("Falling back to _get_api_playinfo()")
+            streams = self._get_api_playinfo(self.match["channel"])
 
-        for stream in streams:
+        for stream in streams or []:
             for stream_format in stream["format"]:
                 for codec in stream_format["codec"]:
                     for url_info in codec["url_info"]:


Test patch:
diff --git a/tests/plugins/test_bilibili.py b/tests/plugins/test_bilibili.py
--- a/tests/plugins/test_bilibili.py
+++ b/tests/plugins/test_bilibili.py
@@ -5,6 +5,6 @@
 class TestPluginCanHandleUrlBilibili(PluginCanHandleUrl):
     __plugin__ = Bilibili
 
-    should_match = [
-        "https://live.bilibili.com/123123123",
+    should_match_groups = [
+        ("https://live.bilibili.com/CHANNEL", {"channel": "CHANNEL"}),
     ]