Repository: streamlink/streamlink

Issue:
plugins.bilibili: support for higher stream qualities
### 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

6.4.2

### Description

You can only get a fixed cookie if you can only get 480P without Cookie. Even if the user does not enter the cookie, it can still be used. This can ensure anonymity

### Debug log

```text
streamlink https://live.bilibili.com/545068 best -p mpv --loglevel=debug
[cli][debug] OS:         Windows 10
[cli][debug] Python:     3.11.6
[cli][debug] OpenSSL:    OpenSSL 3.0.11 19 Sep 2023
[cli][debug] Streamlink: 6.4.2
[cli][debug] Dependencies:
[cli][debug]  certifi: 2023.11.17
[cli][debug]  isodate: 0.6.1
[cli][debug]  lxml: 4.9.3
[cli][debug]  pycountry: 22.3.5
[cli][debug]  pycryptodome: 3.19.0
[cli][debug]  PySocks: 1.7.1
[cli][debug]  requests: 2.31.0
[cli][debug]  trio: 0.23.1
[cli][debug]  trio-websocket: 0.11.1
[cli][debug]  typing-extensions: 4.8.0
[cli][debug]  urllib3: 2.1.0
[cli][debug]  websocket-client: 1.6.4
[cli][debug] Arguments:
[cli][debug]  url=https://live.bilibili.com/545068
[cli][debug]  stream=['best']
[cli][debug]  --loglevel=debug
[cli][debug]  --player=mpv
[cli][info] Found matching plugin bilibili for URL https://live.bilibili.com/545068
[cli][info] Available streams: live_alt, live (worst, best)
[cli][info] Opening stream: live (hls)
[cli][info] Starting player: mpv
[stream.hls][debug] Reloading playlist
[cli][debug] Pre-buffering 8192 bytes
```



Discussion:
> You can only get a fixed cookie if you can only get 480P without Cookie. Even if the user does not enter the cookie, it can still be used. This can ensure anonymity

Not exactly sure what you're trying to say here, but this is probably in regards to your comment here:
https://github.com/streamlink/streamlink/issues/5692#issuecomment-1837536790

No, we won't hardcode any cookie data, and I myself certainly won't use my own phone number on any Chinese sites in order to test logins.

I am aware that you have posted some code in the Go language in #5693 which you have copied from somewhere else and which sets different parameters on the API request compared to the old implementation, but I haven't had a look at this yet.
Do I mean that there is a way to obtain the highest picture quality without using Cookie? The code of the Go language is tested and can get 720P without using cookies
If you can't get the highest quality quality without using Cookie, then I have to look at other methods
The v1 API can still get high quality without cookie, [refer here](https://github.com/SeaHOH/ykdl/blob/master/ykdl/extractors/bilibili/live.py)
> The v1 API can still get high quality without cookie, [refer here](https://github.com/SeaHOH/ykdl/blob/master/ykdl/extractors/bilibili/live.py)

Can this code be integrated?
> The v1 API can still get high quality without cookie, [refer here](https://github.com/SeaHOH/ykdl/blob/master/ykdl/extractors/bilibili/live.py)



> > > 如果没有 Cookie 只能得到 480P，那么只能得到固定的 Cookie。  即使用户不输入cookie，仍然可以使用。  这可以确保匿名性
> > 
> > 
> > 不太确定你在这里想说什么，但这可能与你在这里的评论有关： [第5692章（评论） ](https://github.com/streamlink/streamlink/issues/5692#issuecomment-1837536790)
> > 不，我们不会对任何 cookie 数据进行硬编码，我本人当然也不会在任何中文网站上使用我自己的电话号码来测试登录。
> > 我知道您在 [#5693 ](https://github.com/streamlink/streamlink/pull/5693)中发布了一些 Go 语言代码，这些代码是您从其他地方复制的，并且与旧实现相比，它在 API 请求上设置了不同的参数，但我还没有看过这个。
> 
> If you can't get the highest quality quality without using Cookie, then I have to look at other methods



> > You can only get a fixed cookie if you can only get 480P without Cookie. Even if the user does not enter the cookie, it can still be used. This can ensure anonymity
> 
> Not exactly sure what you're trying to say here, but this is probably in regards to your comment here: [#5692 (comment)](https://github.com/streamlink/streamlink/issues/5692#issuecomment-1837536790)
> 
> No, we won't hardcode any cookie data, and I myself certainly won't use my own phone number on any Chinese sites in order to test logins.
> 
> I am aware that you have posted some code in the Go language in #5693 which you have copied from somewhere else and which sets different parameters on the API request compared to the old implementation, but I haven't had a look at this yet.

The v1 API can still get high quality without cookie, [refer here](https://github.com/SeaHOH/ykdl/blob/master/ykdl/extractors/bilibili/live.py)
> The v1 API can still get high quality without cookie

That is not true. And apart from that, the v1 API only returns FLV container served via progressive HTTP streams instead of TS or fMP4 containers served via HLS or DASH.

Example with room ID 7777:

```sh
streamlink \
  --stdout \
  --loglevel=none \
  "$(curl -sSL \
    'https://api.live.bilibili.com/xlive/web-room/v1/playUrl/playUrl?https_url_req=1&cid=7777&qn=1&platform=web&ptype=16' \
    | jq -r '"httpstream://" + .data.durl[0].url' \
  )" \
  best \
  2>/dev/null \
  | ffprobe -v error -show_format -show_streams -print_format json -
```
```json
{
    "streams": [
        {
            "index": 0,
            "codec_name": "aac",
            "codec_long_name": "AAC (Advanced Audio Coding)",
            "profile": "LC",
            "codec_type": "audio",
            "codec_tag_string": "[0][0][0][0]",
            "codec_tag": "0x0000",
            "sample_fmt": "fltp",
            "sample_rate": "48000",
            "channels": 2,
            "channel_layout": "stereo",
            "bits_per_sample": 0,
            "initial_padding": 0,
            "missing_streams": "0",
            "r_frame_rate": "0/0",
            "avg_frame_rate": "0/0",
            "time_base": "1/1000",
            "start_pts": 0,
            "start_time": "0.000000",
            "bit_rate": "262144",
            "extradata_size": 5,
            "disposition": {
                "default": 0,
                "dub": 0,
                "original": 0,
                "comment": 0,
                "lyrics": 0,
                "karaoke": 0,
                "forced": 0,
                "hearing_impaired": 0,
                "visual_impaired": 0,
                "clean_effects": 0,
                "attached_pic": 0,
                "timed_thumbnails": 0,
                "non_diegetic": 0,
                "captions": 0,
                "descriptions": 0,
                "metadata": 0,
                "dependent": 0,
                "still_image": 0
            }
        },
        {
            "index": 1,
            "codec_name": "h264",
            "codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
            "profile": "High",
            "codec_type": "video",
            "codec_tag_string": "[0][0][0][0]",
            "codec_tag": "0x0000",
            "width": 854,
            "height": 480,
            "coded_width": 854,
            "coded_height": 480,
            "closed_captions": 0,
            "film_grain": 0,
            "has_b_frames": 2,
            "pix_fmt": "yuv420p",
            "level": 31,
            "color_range": "tv",
            "color_space": "bt709",
            "color_transfer": "bt709",
            "color_primaries": "bt709",
            "chroma_location": "left",
            "field_order": "progressive",
            "refs": 1,
            "is_avc": "true",
            "nal_length_size": "4",
            "missing_streams": "0",
            "r_frame_rate": "30/1",
            "avg_frame_rate": "30/1",
            "time_base": "1/1000",
            "start_pts": 68,
            "start_time": "0.068000",
            "bits_per_raw_sample": "8",
            "extradata_size": 48,
            "disposition": {
                "default": 0,
                "dub": 0,
                "original": 0,
                "comment": 0,
                "lyrics": 0,
                "karaoke": 0,
                "forced": 0,
                "hearing_impaired": 0,
                "visual_impaired": 0,
                "clean_effects": 0,
                "attached_pic": 0,
                "timed_thumbnails": 0,
                "non_diegetic": 0,
                "captions": 0,
                "descriptions": 0,
                "metadata": 0,
                "dependent": 0,
                "still_image": 0
            }
        }
    ],
    "format": {
        "filename": "fd:",
        "nb_streams": 2,
        "nb_programs": 0,
        "format_name": "flv",
        "format_long_name": "FLV (Flash Video)",
        "start_time": "0.000000",
        "duration": "0.000000",
        "size": "0",
        "probe_score": 100,
        "tags": {
            "displayHeight": "480",
            "level": "",
            "cdn_ip": "10.12.13.22",
            "profile": "",
            "displayWidth": "854",
            "Rawdata": "",
            "encoder": "libobs/28.1.2 LiveHime/4.52.0 (Windows),BILIAVC.1.6.6",
            "fps": "30"
        }
    }
}
```
can only return to low quality? I see the buddy above said
it seems like the older version of streamlink can still record high quality. I use the streamlink 5.2.1 on Debian 11. when I upgrade to newer 6.5.0-1~bpo12+1. The record is low quality with 854x480 resolution. then I switch back to 5.2.1. the record is 1920x1080. 
But the older version 5.2.1 can not get the roomtitle. 
according to [直播间信息流 获取秘钥需要Cookie](https://github.com/SocialSisterYi/bilibili-API-collect/issues/846), I set `--http-cookie ` in configuration with `DedeUserID__ckMd5` `DedeUserID` `SESSDATA` `bili_jct`. Streamlink can get high quality with version  6.5.1
The plugin implementation prior to Streamlink's `6.4.2` release was based on the site's v1 API. The plugin was rewritten based on the v2 API because the API response changed and the plugin's validation schema broke. v2 is used by their website, hence the plugin-reimplementation on that.

I have no idea about the status of this v1 API and whether it's deprecated and will be shut down in the future.

As you can see in my last post, I had checked the claims of the stream quality, but the results showed low-quality streams. Apparently, this was the wrong v1 URL. The old plugin implementation used a different one.

Please have a look at #5782. I've added back progressive HTTP streams from the previously used v1 API response, which are indeed high quality streams. I decided to not remove the HLS streams.

Please check a couple of streams and see if there are any issues.
https://github.com/streamlink/streamlink/blob/master/CONTRIBUTING.md#pull-request-feedback

I won't merge #5782 until I have confirmation that this new plugin implementation is working on all streams.



Patch:
diff --git a/src/streamlink/plugins/bilibili.py b/src/streamlink/plugins/bilibili.py
index 1a5a1121aef..cebd7482aa7 100644
--- a/src/streamlink/plugins/bilibili.py
+++ b/src/streamlink/plugins/bilibili.py
@@ -10,6 +10,7 @@
 from streamlink.exceptions import NoStreamsError
 from streamlink.plugin import Plugin, pluginmatcher
 from streamlink.plugin.api import validate
+from streamlink.stream import HTTPStream
 from streamlink.stream.hls import HLSStream
 
 
@@ -17,17 +18,64 @@
 
 
 @pluginmatcher(
-    re.compile(r"https?://live\.bilibili\.com/(?P<channel>[^/]+)"),
+    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"
+    _URL_API_V1_PLAYURL = "https://api.live.bilibili.com/room/v1/Room/playUrl"
+    _URL_API_V2_PLAYINFO = "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo"
 
     SHOW_STATUS_OFFLINE = 0
     SHOW_STATUS_ONLINE = 1
     SHOW_STATUS_ROUND = 2
 
-    @staticmethod
-    def _schema_streams():
+    @classmethod
+    def stream_weight(cls, stream):
+        offset = 1 if "_alt" in stream else 0
+        if stream.startswith("httpstream"):
+            return 4 - offset, stream
+        if stream.startswith("hls"):
+            return 2 - offset, stream
+        return super().stream_weight(stream)
+
+    def _get_api_v1_playurl(self, room_id):
+        return self.session.http.get(
+            self._URL_API_V1_PLAYURL,
+            params={
+                "cid": room_id,
+                "platform": "web",
+                "quality": "4",
+            },
+            schema=validate.Schema(
+                validate.parse_json(),
+                validate.any(
+                    validate.all(
+                        {
+                            "code": 0,
+                            "data": {
+                                "durl": [
+                                    validate.all(
+                                        {
+                                            "url": validate.url(),
+                                        },
+                                        validate.get("url"),
+                                    ),
+                                ],
+                            },
+                        },
+                        validate.get(("data", "durl")),
+                    ),
+                    validate.all(
+                        {
+                            "code": int,
+                        },
+                        validate.transform(lambda _: []),
+                    ),
+                ),
+            ),
+        )
+
+    @property
+    def _schema_v2_streams(self):
         return validate.all(
             [
                 {
@@ -60,9 +108,9 @@ def _schema_streams():
             validate.filter(lambda item: item["protocol_name"] == "http_hls"),
         )
 
-    def _get_api_playinfo(self, room_id):
+    def _get_api_v2_playinfo(self, room_id):
         return self.session.http.get(
-            self._URL_API_PLAYINFO,
+            self._URL_API_V2_PLAYINFO,
             params={
                 "room_id": room_id,
                 "no_playurl": 0,
@@ -83,7 +131,7 @@ def _get_api_playinfo(self, room_id):
                         "playurl_info": validate.none_or_all(
                             {
                                 "playurl": {
-                                    "stream": self._schema_streams(),
+                                    "stream": self._schema_v2_streams,
                                 },
                             },
                             validate.get(("playurl", "stream")),
@@ -110,7 +158,7 @@ def _get_page_playinfo(self):
                                 "playurl_info": validate.none_or_all(
                                     {
                                         "playurl": {
-                                            "stream": self._schema_streams(),
+                                            "stream": self._schema_v2_streams,
                                         },
                                     },
                                     validate.get(("playurl", "stream")),
@@ -154,17 +202,23 @@ def _get_page_playinfo(self):
         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 or []:
-            for stream_format in stream["format"]:
+        http_streams = self._get_api_v1_playurl(self.match["channel"])
+        for http_stream in http_streams:
+            if self.session.http.head(http_stream, raise_for_status=False).status_code >= 400:
+                continue
+            yield "httpstream", HTTPStream(self.session, http_stream)
+
+        hls_streams = self._get_page_playinfo()
+        if not hls_streams:
+            log.debug("Falling back to _get_api_v2_playinfo()")
+            hls_streams = self._get_api_v2_playinfo(self.match["channel"])
+
+        for hls_stream in hls_streams or []:
+            for stream_format in hls_stream["format"]:
                 for codec in stream_format["codec"]:
                     for url_info in codec["url_info"]:
                         url = f"{url_info['host']}{codec['base_url']}{url_info['extra']}"
-                        yield "live", HLSStream(self.session, url)
+                        yield "hls", HLSStream(self.session, url)
 
 
 __plugin__ = Bilibili


Test patch:
diff --git a/tests/plugins/test_bilibili.py b/tests/plugins/test_bilibili.py
index 0ac77acfbbd..69b65c6f93f 100644
--- a/tests/plugins/test_bilibili.py
+++ b/tests/plugins/test_bilibili.py
@@ -6,5 +6,5 @@ class TestPluginCanHandleUrlBilibili(PluginCanHandleUrl):
     __plugin__ = Bilibili
 
     should_match_groups = [
-        ("https://live.bilibili.com/CHANNEL", {"channel": "CHANNEL"}),
+        ("https://live.bilibili.com/CHANNEL?live_from=78001", {"channel": "CHANNEL"}),
     ]