mirror of
https://github.com/czp3009/bilibili-api.git
synced 2024-12-21 20:30:28 +08:00
完成直播间实时弹幕的获取
This commit is contained in:
parent
fa3b1d974c
commit
f2984bae0a
91
README.md
91
README.md
@ -377,5 +377,96 @@ bilibiliClient.mainAPI.sendDanmaku(aid = 40675923, cid = 71438168, progress = 22
|
||||
|
||||
如果不确定视频的长度, 需要从[视频播放地址的 API](#获取视频播放地址) 中的 `data.timelength` 来获得, 单位也是毫秒.
|
||||
|
||||
## 获取直播弹幕
|
||||
刚进入直播间时, 看到的十条弹幕实际上是最近的历史弹幕, 通过以下方式来获取
|
||||
|
||||
```kotlin
|
||||
bilibiliClient.liveAPI.roomMessage(roomId).await()
|
||||
```
|
||||
|
||||
接下来的弹幕都是实时弹幕, 直播间实时弹幕通过 `Websocket` 来推送.
|
||||
|
||||
```kotlin
|
||||
bilibiliClient.liveClient(
|
||||
roomId = 3,
|
||||
onConnect = {
|
||||
println("Connected")
|
||||
},
|
||||
onPopularityPacket = { _, popularity ->
|
||||
println("Current popularity: $popularity")
|
||||
},
|
||||
onCommandPacket = { _, jsonObject ->
|
||||
println(jsonObject)
|
||||
},
|
||||
onClose = { _, closeReason ->
|
||||
println(closeReason)
|
||||
}
|
||||
).start()
|
||||
```
|
||||
|
||||
服务器推送的 `Message` 有两种, 一种是 `人气值` 数据, 另一种是 `Command` 数据.
|
||||
|
||||
`Command` 数据包用于控制客户端渲染何种内容. 弹幕, 送礼, 系统公告等全部都是由 `Command` 数据包控制的, 其本体为一个 `JsonObject`.
|
||||
|
||||
例如一个弹幕数据是这样的(`cmd` 字段的值为 `DANMU_MSG`):
|
||||
|
||||
```json
|
||||
{"cmd":"DANMU_MSG","info":[[0,1,25,16777215,1553417856,1553414245,0,"9e539d78",0,0,0],"记得存档!",[3432444,"喵的叫一声",0,0,0,10000,1,""],[6,"日常","奶粉の日常",35399,5805790,""],[22,0,5805790,">50000"],["",""],0,0,null,{"ts":1553417856,"ct":"87255D9C"}]}
|
||||
```
|
||||
|
||||
`Welcome` 的数据是这样的
|
||||
```json
|
||||
{"cmd":"WELCOME","data":{"uid":110208099,"uname":"霸刀宋壹i","is_admin":false,"svip":1}}
|
||||
```
|
||||
|
||||
各种 `Command` 数据包的结构经常改变, 因此不提供实体类.
|
||||
|
||||
由于 `DANMU_MSG` 的数据结构太过意识流, 因此提供了额外的辅助工具来方便地解析它.
|
||||
|
||||
`DanmakuMessage` 是一个 `inline class` 请不要对其进行太过复杂的操作.
|
||||
|
||||
```kotlin
|
||||
onCommandPacket = { _, jsonObject ->
|
||||
val cmd by jsonObject.byString
|
||||
println(
|
||||
if (cmd == "DANMU_MSG") {
|
||||
with(DanmakuMessage(jsonObject)) {
|
||||
"${if (fansMedalInfo.isNotEmpty()) "[$fansMedalName $fansMedalLevel] " else ""}[UL$userLevel] $nickname: $message"
|
||||
}
|
||||
} else {
|
||||
jsonObject.toString()
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```
|
||||
[甜甜天 7] [UL25] czp3009: 233
|
||||
```
|
||||
|
||||
更多 `Command` 数据包的数据结构详见本项目的 [/record/直播弹幕](record/直播弹幕) 文件夹.
|
||||
|
||||
注意, `start()` 方法会 suspend 当前协程直到连接关闭, 如果当前协程上下文还需要执行更多逻辑则如下所示
|
||||
|
||||
```kotlin
|
||||
val liveClient = bilibiliClient.liveClient(args)
|
||||
launch { liveClient.start() }
|
||||
println("We do more thing here")
|
||||
delay(100_000)
|
||||
liveClient.close()
|
||||
```
|
||||
|
||||
如果要实现断线重连, 需要在额外的协程中进行连接操作, 例如 `onClose` 回调如下所示(手动调用 `close()` 方法也会触发 `onClose` 回调, 请用额外变量来记录该次关闭是否是最终用户的行为)
|
||||
|
||||
```kotlin
|
||||
onClose = { liveClient, closeReason ->
|
||||
launch {
|
||||
liveClient.start()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# License
|
||||
GPL V3
|
||||
|
@ -38,7 +38,7 @@ dependencies {
|
||||
compileKotlin {
|
||||
kotlinOptions {
|
||||
jvmTarget = jvm_target
|
||||
freeCompilerArgs = ["-Xjvm-default=enable", "-Xuse-experimental=kotlin.Experimental"]
|
||||
freeCompilerArgs = ["-Xjvm-default=enable", "-Xuse-experimental=kotlin.Experimental", "-XXLanguage:+InlineClasses"]
|
||||
}
|
||||
}
|
||||
compileTestKotlin {
|
||||
|
14
record/直播弹幕/COMBO_END.json
Normal file
14
record/直播弹幕/COMBO_END.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"cmd": "COMBO_END",
|
||||
"data": {
|
||||
"uname": "by_a_second",
|
||||
"r_uname": "黑桐谷歌",
|
||||
"combo_num": 3,
|
||||
"price": 3000,
|
||||
"gift_name": "给代打的礼物",
|
||||
"gift_id": 30051,
|
||||
"start_time": 1553410146,
|
||||
"end_time": 1553410148,
|
||||
"guard_level": 0
|
||||
}
|
||||
}
|
12
record/直播弹幕/COMBO_SEND.json
Normal file
12
record/直播弹幕/COMBO_SEND.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"cmd": "COMBO_SEND",
|
||||
"data": {
|
||||
"uid": 16811396,
|
||||
"uname": "by_a_second",
|
||||
"combo_num": 3,
|
||||
"gift_name": "给代打的礼物",
|
||||
"gift_id": 30051,
|
||||
"action": "赠送",
|
||||
"combo_id": "gift:combo_id:16811396:43536:30051:1553410146.471"
|
||||
}
|
||||
}
|
54
record/直播弹幕/DANMU_MSG.json
Normal file
54
record/直播弹幕/DANMU_MSG.json
Normal file
@ -0,0 +1,54 @@
|
||||
{
|
||||
"cmd": "DANMU_MSG",
|
||||
"info": [
|
||||
[
|
||||
0,
|
||||
1,
|
||||
25,
|
||||
16750592,
|
||||
1553368447,
|
||||
1772673920,
|
||||
0,
|
||||
"169cc1f9",
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"这头衔永久的?",
|
||||
[
|
||||
9973581,
|
||||
"丧糕菌",
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
10000,
|
||||
1,
|
||||
""
|
||||
],
|
||||
[
|
||||
17,
|
||||
"丧病",
|
||||
"扎双马尾的丧尸",
|
||||
48499,
|
||||
16752445,
|
||||
""
|
||||
],
|
||||
[
|
||||
42,
|
||||
0,
|
||||
16746162,
|
||||
13011
|
||||
],
|
||||
[
|
||||
"title-198-1",
|
||||
"title-198-1"
|
||||
],
|
||||
0,
|
||||
0,
|
||||
null,
|
||||
{
|
||||
"ts": 1553368447,
|
||||
"ct": "98688F2F"
|
||||
}
|
||||
]
|
||||
}
|
22
record/直播弹幕/ENTRY_EFFECT.json
Normal file
22
record/直播弹幕/ENTRY_EFFECT.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"cmd": "ENTRY_EFFECT",
|
||||
"data": {
|
||||
"id": 4,
|
||||
"uid": 3007159,
|
||||
"target_id": 43536,
|
||||
"mock_effect": 0,
|
||||
"face": "https://i0.hdslb.com/bfs/face/7c071f180a20512eba29e80bb13d1c8a3fe3916a.jpg",
|
||||
"privilege_type": 3,
|
||||
"copy_writing": "欢迎舰长 <%goodby...%> 进入直播间",
|
||||
"copy_color": "",
|
||||
"highlight_color": "#E6FF00",
|
||||
"priority": 70,
|
||||
"basemap_url": "https://i0.hdslb.com/bfs/live/1fa3cc06258e16c0ac4c209e2645fda3c2791894.png",
|
||||
"show_avatar": 1,
|
||||
"effective_time": 2,
|
||||
"web_basemap_url": "",
|
||||
"web_effective_time": 0,
|
||||
"web_effect_close": 0,
|
||||
"web_close_time": 0
|
||||
}
|
||||
}
|
14
record/直播弹幕/GUARD_BUY.json
Normal file
14
record/直播弹幕/GUARD_BUY.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"cmd": "GUARD_BUY",
|
||||
"data": {
|
||||
"uid": 1781654,
|
||||
"username": "renbye",
|
||||
"guard_level": 3,
|
||||
"num": 1,
|
||||
"price": 198000,
|
||||
"gift_id": 10003,
|
||||
"gift_name": "舰长",
|
||||
"start_time": 1553429698,
|
||||
"end_time": 1553429698
|
||||
}
|
||||
}
|
27
record/直播弹幕/GUARD_LOTTERY_START.json
Normal file
27
record/直播弹幕/GUARD_LOTTERY_START.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"cmd": "GUARD_LOTTERY_START",
|
||||
"data": {
|
||||
"id": 955580,
|
||||
"roomid": 1029,
|
||||
"message": "renbye 在【1029】购买了舰长,请前往抽奖",
|
||||
"type": "guard",
|
||||
"privilege_type": 3,
|
||||
"link": "https://live.bilibili.com/1029",
|
||||
"payflow_id": "gds_74e19a449c1fdaaa73_201903",
|
||||
"lottery": {
|
||||
"id": 955580,
|
||||
"sender": {
|
||||
"uid": 1781654,
|
||||
"uname": "renbye",
|
||||
"face": "http://i1.hdslb.com/bfs/face/0b7a8be6e5d2a89a7de7ccd211a529599f03284e.jpg"
|
||||
},
|
||||
"keyword": "guard",
|
||||
"privilege_type": 3,
|
||||
"time": 1200,
|
||||
"status": 1,
|
||||
"mobile_display_mode": 2,
|
||||
"mobile_static_asset": "",
|
||||
"mobile_animation_asset": ""
|
||||
}
|
||||
}
|
||||
}
|
9
record/直播弹幕/GUARD_MSG.json
Normal file
9
record/直播弹幕/GUARD_MSG.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"cmd": "GUARD_MSG",
|
||||
"msg": "用户 :?鱼仔是橙子的小祖宗:? 在主播 鱼仔一点都不困 的直播间开通了总督",
|
||||
"msg_new": "<%鱼仔是橙子的小祖宗%> 在 <%鱼仔一点都不困%> 的房间开通了总督并触发了抽奖,点击前往TA的房间去抽奖吧",
|
||||
"url": "https://live.bilibili.com/46744",
|
||||
"roomid": 46744,
|
||||
"buy_type": 1,
|
||||
"broadcast_type": 0
|
||||
}
|
37
record/直播弹幕/NOTICE_MSG.json
Normal file
37
record/直播弹幕/NOTICE_MSG.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"cmd": "NOTICE_MSG",
|
||||
"full": {
|
||||
"head_icon": "http://i0.hdslb.com/bfs/live/b29add66421580c3e680d784a827202e512a40a0.webp",
|
||||
"tail_icon": "http://i0.hdslb.com/bfs/live/822da481fdaba986d738db5d8fd469ffa95a8fa1.webp",
|
||||
"head_icon_fa": "http://i0.hdslb.com/bfs/live/49869a52d6225a3e70bbf1f4da63f199a95384b2.png",
|
||||
"tail_icon_fa": "http://i0.hdslb.com/bfs/live/38cb2a9f1209b16c0f15162b0b553e3b28d9f16f.png",
|
||||
"head_icon_fan": 24,
|
||||
"tail_icon_fan": 4,
|
||||
"background": "#66A74EFF",
|
||||
"color": "#FFFFFFFF",
|
||||
"highlight": "#FDFF2FFF",
|
||||
"time": 20
|
||||
},
|
||||
"half": {
|
||||
"head_icon": "http://i0.hdslb.com/bfs/live/ec9b374caec5bd84898f3780a10189be96b86d4e.png",
|
||||
"tail_icon": "",
|
||||
"background": "#85B971FF",
|
||||
"color": "#FFFFFFFF",
|
||||
"highlight": "#FDFF2FFF",
|
||||
"time": 15
|
||||
},
|
||||
"side": {
|
||||
"head_icon": "http://i0.hdslb.com/bfs/live/e41c7e12b1e08724d2ab2f369515132d30fe1ef7.png",
|
||||
"background": "#F4FDE8FF",
|
||||
"color": "#79B48EFF",
|
||||
"highlight": "#388726FF",
|
||||
"border": "#A9DA9FFF"
|
||||
},
|
||||
"roomid": 12124934,
|
||||
"real_roomid": 12124934,
|
||||
"msg_common": "全区广播:<%小苏棠の大脸猫脸大%>送给<%小苏棠i%>1个小电视飞船,点击前往TA的房间去抽奖吧",
|
||||
"msg_self": "全区广播:<%小苏棠の大脸猫脸大%>送给<%小苏棠i%>1个小电视飞船,快来抽奖吧",
|
||||
"link_url": "http://live.bilibili.com/12124934?live_lottery_type=1&broadcast_type=0&from=28003&extra_jump_from=28003",
|
||||
"msg_type": 2,
|
||||
"shield_uid": -1
|
||||
}
|
11
record/直播弹幕/ROOM_BLOCK_MSG.json
Normal file
11
record/直播弹幕/ROOM_BLOCK_MSG.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"cmd": "ROOM_BLOCK_MSG",
|
||||
"uid": 8305711,
|
||||
"uname": "RMT0v0",
|
||||
"data": {
|
||||
"uid": 8305711,
|
||||
"uname": "RMT0v0",
|
||||
"operator": 1
|
||||
},
|
||||
"roomid": 1029
|
||||
}
|
11
record/直播弹幕/ROOM_RANK.json
Normal file
11
record/直播弹幕/ROOM_RANK.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"cmd": "ROOM_RANK",
|
||||
"data": {
|
||||
"roomid": 1029,
|
||||
"rank_desc": "单机小时榜 13",
|
||||
"color": "#FB7299",
|
||||
"h5_url": "https://live.bilibili.com/p/html/live-app-rankcurrent/index.html?is_live_half_webview=1&hybrid_half_ui=1,5,85p,70p,FFE293,0,30,100,10;2,2,320,100p,FFE293,0,30,100,0;4,2,320,100p,FFE293,0,30,100,0;6,5,65p,60p,FFE293,0,30,100,10;5,5,55p,60p,FFE293,0,30,100,10;3,5,85p,70p,FFE293,0,30,100,10;7,5,65p,60p,FFE293,0,30,100,10;&anchor_uid=43536&rank_type=master_realtime_area_hour&area_hour=1&area_v2_id=245&area_v2_parent_id=6",
|
||||
"web_url": "https://live.bilibili.com/blackboard/room-current-rank.html?rank_type=master_realtime_area_hour&area_hour=1&area_v2_id=245&area_v2_parent_id=6",
|
||||
"timestamp": 1553409901
|
||||
}
|
||||
}
|
7
record/直播弹幕/ROOM_REAL_TIME_MESSAGE_UPDATE.json
Normal file
7
record/直播弹幕/ROOM_REAL_TIME_MESSAGE_UPDATE.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"cmd": "ROOM_REAL_TIME_MESSAGE_UPDATE",
|
||||
"data": {
|
||||
"roomid": 23058,
|
||||
"fans": 300958
|
||||
}
|
||||
}
|
9
record/直播弹幕/ROOM_SILENT_ON.json
Normal file
9
record/直播弹幕/ROOM_SILENT_ON.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"cmd": "ROOM_SILENT_ON",
|
||||
"data": {
|
||||
"type": "level",
|
||||
"level": 20,
|
||||
"second": -1
|
||||
},
|
||||
"roomid": 1029
|
||||
}
|
44
record/直播弹幕/SEND_GIFT.json
Normal file
44
record/直播弹幕/SEND_GIFT.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"cmd": "SEND_GIFT",
|
||||
"data": {
|
||||
"giftName": "辣条",
|
||||
"num": 62,
|
||||
"uname": "萌萌哒熊宝宝",
|
||||
"face": "http://i0.hdslb.com/bfs/face/33570159b6bf28e01249b80d3f9f05fa117779c1.jpg",
|
||||
"guard_level": 0,
|
||||
"rcost": 123266565,
|
||||
"uid": 10007727,
|
||||
"top_list": [],
|
||||
"timestamp": 1553369191,
|
||||
"giftId": 1,
|
||||
"giftType": 0,
|
||||
"action": "喂食",
|
||||
"super": 0,
|
||||
"super_gift_num": 0,
|
||||
"price": 100,
|
||||
"rnd": "940348243",
|
||||
"newMedal": 0,
|
||||
"newTitle": 0,
|
||||
"medal": [],
|
||||
"title": "",
|
||||
"beatId": "",
|
||||
"biz_source": "live",
|
||||
"metadata": "",
|
||||
"remain": 0,
|
||||
"gold": 0,
|
||||
"silver": 0,
|
||||
"eventScore": 0,
|
||||
"eventNum": 0,
|
||||
"smalltv_msg": [],
|
||||
"specialGift": null,
|
||||
"notice_msg": [],
|
||||
"capsule": null,
|
||||
"addFollow": 0,
|
||||
"effect_block": 1,
|
||||
"coin_type": "silver",
|
||||
"total_coin": 6200,
|
||||
"effect": 0,
|
||||
"tag_image": "",
|
||||
"user_count": 0
|
||||
}
|
||||
}
|
14
record/直播弹幕/SYS_MSG.json
Normal file
14
record/直播弹幕/SYS_MSG.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"cmd": "SYS_MSG",
|
||||
"msg": "小苏棠の大脸猫脸大:?送给:?小苏棠i:?1个小电视飞船,点击前往TA的房间去抽奖吧",
|
||||
"msg_text": "小苏棠の大脸猫脸大:?送给:?小苏棠i:?1个小电视飞船,点击前往TA的房间去抽奖吧",
|
||||
"msg_common": "全区广播:<%小苏棠の大脸猫脸大%>送给<%小苏棠i%>1个小电视飞船,点击前往TA的房间去抽奖吧",
|
||||
"msg_self": "全区广播:<%小苏棠の大脸猫脸大%>送给<%小苏棠i%>1个小电视飞船,快来抽奖吧",
|
||||
"rep": 1,
|
||||
"styleType": 2,
|
||||
"url": "http://live.bilibili.com/12124934",
|
||||
"roomid": 12124934,
|
||||
"real_roomid": 12124934,
|
||||
"rnd": 1553410466,
|
||||
"broadcast_type": 0
|
||||
}
|
10
record/直播弹幕/USER_TOAST_MSG.json
Normal file
10
record/直播弹幕/USER_TOAST_MSG.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"cmd": "USER_TOAST_MSG",
|
||||
"data": {
|
||||
"op_type": 1,
|
||||
"uid": 1781654,
|
||||
"username": "renbye",
|
||||
"guard_level": 3,
|
||||
"is_show": 0
|
||||
}
|
||||
}
|
9
record/直播弹幕/WELCOME.json
Normal file
9
record/直播弹幕/WELCOME.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"cmd": "WELCOME",
|
||||
"data": {
|
||||
"uid": 3173595,
|
||||
"uname": "百杜Paido",
|
||||
"is_admin": false,
|
||||
"svip": 1
|
||||
}
|
||||
}
|
8
record/直播弹幕/WELCOME_GUARD.json
Normal file
8
record/直播弹幕/WELCOME_GUARD.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"cmd": "WELCOME_GUARD",
|
||||
"data": {
|
||||
"uid": 3007159,
|
||||
"username": "goodbyecaroline",
|
||||
"guard_level": 3
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
package com.hiczp.bilibili.api
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
import com.hiczp.bilibili.api.app.AppAPI
|
||||
import com.hiczp.bilibili.api.danmaku.DanmakuAPI
|
||||
import com.hiczp.bilibili.api.live.LiveAPI
|
||||
@ -20,6 +21,7 @@ import com.hiczp.bilibili.api.retrofit.interceptor.FailureResponseInterceptor
|
||||
import com.hiczp.bilibili.api.retrofit.interceptor.SortAndSignInterceptor
|
||||
import com.hiczp.bilibili.api.vc.VcAPI
|
||||
import com.jakewharton.retrofit2.adapter.kotlin.coroutines.CoroutineCallAdapterFactory
|
||||
import io.ktor.http.cio.websocket.CloseReason
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.future.future
|
||||
import okhttp3.Interceptor
|
||||
@ -245,8 +247,15 @@ class BilibiliClient(
|
||||
roomId: Long,
|
||||
fetchRoomId: Boolean = true,
|
||||
fetchDanmakuConfig: Boolean = true,
|
||||
doEntryRoomAction: Boolean = false
|
||||
) = LiveClient(this, roomId, fetchRoomId, fetchDanmakuConfig, doEntryRoomAction)
|
||||
doEntryRoomAction: Boolean = false,
|
||||
onConnect: (LiveClient) -> Unit,
|
||||
onPopularityPacket: (LiveClient, Int) -> Unit,
|
||||
onCommandPacket: (LiveClient, JsonObject) -> Unit,
|
||||
onClose: (LiveClient, CloseReason?) -> Unit
|
||||
) = LiveClient(
|
||||
this, roomId, fetchRoomId, fetchDanmakuConfig, doEntryRoomAction,
|
||||
onConnect, onPopularityPacket, onCommandPacket, onClose
|
||||
)
|
||||
|
||||
/**
|
||||
* 登陆
|
||||
|
9
src/main/kotlin/com/hiczp/bilibili/api/GsonExtension.kt
Normal file
9
src/main/kotlin/com/hiczp/bilibili/api/GsonExtension.kt
Normal file
@ -0,0 +1,9 @@
|
||||
package com.hiczp.bilibili.api
|
||||
|
||||
import com.google.gson.JsonArray
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun JsonArray.isEmpty() = size() == 0
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun JsonArray.isNotEmpty() = size() != 0
|
@ -36,4 +36,4 @@ fun InputStream.bounded(size: Long) = BoundedInputStream(this, size)
|
||||
fun InputStream.bounded(size: UInt) = bounded(size.toLong())
|
||||
|
||||
@UseExperimental(InternalAPI::class)
|
||||
internal fun ByteArray.toPrettyPrintString() = joinToString(prefix = "[", postfix = "]") { "%02x".format(it) }
|
||||
internal fun ByteArray.toPrettyPrintString() = joinToString(prefix = "[", postfix = "]") { "0x%02x".format(it) }
|
||||
|
@ -1,17 +1,23 @@
|
||||
package com.hiczp.bilibili.api.live.websocket
|
||||
|
||||
import com.google.gson.JsonObject
|
||||
import com.hiczp.bilibili.api.BilibiliClient
|
||||
import com.hiczp.bilibili.api.jsonParser
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.cio.CIO
|
||||
import io.ktor.client.features.websocket.WebSockets
|
||||
import io.ktor.client.features.websocket.wss
|
||||
import io.ktor.http.cio.websocket.CloseReason
|
||||
import io.ktor.http.cio.websocket.WebSocketSession
|
||||
import io.ktor.http.cio.websocket.close
|
||||
import io.ktor.util.InternalAPI
|
||||
import io.ktor.util.KtorExperimentalAPI
|
||||
import io.ktor.util.decodeString
|
||||
import kotlinx.coroutines.ObsoleteCoroutinesApi
|
||||
import kotlinx.coroutines.channels.consumeEach
|
||||
import kotlinx.coroutines.channels.map
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.io.errors.IOException
|
||||
import java.math.BigInteger
|
||||
|
||||
/**
|
||||
* 直播客户端
|
||||
@ -20,22 +26,35 @@ import java.math.BigInteger
|
||||
* @param fetchRoomId 是否在连接前先获取房间号(长号)
|
||||
* @param fetchDanmakuConfig 是否在连接前先获取弹幕推送服务器地址
|
||||
* @param doEntryRoomAction 是否产生直播间观看历史记录
|
||||
* @param onConnect 回调函数, 连接成功时触发
|
||||
* @param onPopularityPacket 回调函数, 接收到人气值数据包时触发
|
||||
* @param onCommandPacket 回调函数, 接收到 Command 数据包时触发
|
||||
* @param onClose 回调函数, 连接断开时触发
|
||||
*/
|
||||
@Suppress("CanBeParameter")
|
||||
class LiveClient(
|
||||
private val bilibiliClient: BilibiliClient,
|
||||
private val maybeShortRoomId: Long,
|
||||
private val fetchRoomId: Boolean = true,
|
||||
private val fetchDanmakuConfig: Boolean = true,
|
||||
private val doEntryRoomAction: Boolean = false
|
||||
private val doEntryRoomAction: Boolean = false,
|
||||
private val onConnect: (LiveClient) -> Unit,
|
||||
private val onPopularityPacket: (LiveClient, Int) -> Unit,
|
||||
private val onCommandPacket: (LiveClient, JsonObject) -> Unit,
|
||||
private val onClose: (LiveClient, CloseReason?) -> Unit
|
||||
) {
|
||||
private val liveAPI = bilibiliClient.liveAPI
|
||||
private var websocketSession: WebSocketSession? = null
|
||||
|
||||
var roomId = maybeShortRoomId
|
||||
private set
|
||||
|
||||
@UseExperimental(KtorExperimentalAPI::class, kotlinx.coroutines.ObsoleteCoroutinesApi::class)
|
||||
/**
|
||||
* 开启连接, 注意此方法是 suspend 的
|
||||
*/
|
||||
@UseExperimental(KtorExperimentalAPI::class, ObsoleteCoroutinesApi::class, InternalAPI::class)
|
||||
suspend fun start() {
|
||||
val liveAPI = bilibiliClient.liveAPI
|
||||
|
||||
//得到原始房间号和房间主用户ID
|
||||
//得到原始房间号和主播的用户ID
|
||||
var anchorUserId = 0L
|
||||
if (fetchRoomId) {
|
||||
liveAPI.mobileRoomInit(maybeShortRoomId).await().data.also {
|
||||
@ -46,7 +65,7 @@ class LiveClient(
|
||||
|
||||
//获得 wss 地址和端口(推荐服务器)
|
||||
@Suppress("SpellCheckingInspection")
|
||||
var host = "tx-hk-live-comet-01.chat.bilibili.com"
|
||||
var host = "broadcastlv.chat.bilibili.com"
|
||||
var port = 443
|
||||
if (fetchDanmakuConfig) {
|
||||
liveAPI.getDanmakuConfig(roomId).await().data.also { data ->
|
||||
@ -63,37 +82,60 @@ class LiveClient(
|
||||
|
||||
//开启 websocket
|
||||
HttpClient(CIO).config { install(WebSockets) }.wss(host = host, port = port, path = "/sub") {
|
||||
websocketSession = this
|
||||
pingIntervalMillis = -1
|
||||
|
||||
try {
|
||||
//发送进房数据包
|
||||
send(PresetPacket.enterRoomPacket(anchorUserId, roomId))
|
||||
if (incoming.receive().toPacket().packetType != PacketType.ENTER_ROOM_RESPONSE) {
|
||||
//impossible
|
||||
close(IOException("Receive incorrect server response"))
|
||||
//发送进房数据包
|
||||
send(PresetPacket.enterRoomPacket(anchorUserId, roomId))
|
||||
if (incoming.receive().toPackets()[0].packetType == PacketType.ENTER_ROOM_RESPONSE) {
|
||||
try {
|
||||
onConnect(this@LiveClient)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
} else {
|
||||
//impossible
|
||||
close(IOException("Receive unreadable server response"))
|
||||
}
|
||||
|
||||
//发送心跳包
|
||||
launch {
|
||||
while (true) {
|
||||
send(PresetPacket.heartbeatPacket())
|
||||
delay(30_000)
|
||||
//发送心跳包
|
||||
launch {
|
||||
while (true) {
|
||||
send(PresetPacket.heartbeatPacket())
|
||||
delay(30_000)
|
||||
}
|
||||
}
|
||||
|
||||
incoming.consumeEach { frame ->
|
||||
frame.toPackets().forEach {
|
||||
try {
|
||||
@Suppress("NON_EXHAUSTIVE_WHEN")
|
||||
when (it.packetType) {
|
||||
PacketType.POPULARITY -> onPopularityPacket(this@LiveClient, it.content.int)
|
||||
PacketType.COMMAND -> onCommandPacket(this@LiveClient, jsonParser.parse(it.content.decodeString()).asJsonObject)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
incoming.map { it.toPacket() }.consumeEach {
|
||||
println(
|
||||
when (it.packetType) {
|
||||
PacketType.POPULARITY -> "Current popularity: ${BigInteger(it.content).longValueExact()}"
|
||||
PacketType.COMMAND -> "${it.getJsonContent()}"
|
||||
else -> "Other packet: $it"
|
||||
}
|
||||
)
|
||||
launch {
|
||||
val closeReason = closeReason.await()
|
||||
try {
|
||||
onClose(this@LiveClient, closeReason)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭连接
|
||||
*/
|
||||
suspend fun close() = websocketSession?.run {
|
||||
websocketSession = null
|
||||
close(CloseReason(CloseReason.Codes.NORMAL, "user close"))
|
||||
} ?: Unit
|
||||
}
|
||||
|
@ -1,125 +1,74 @@
|
||||
package com.hiczp.bilibili.api.live.websocket
|
||||
|
||||
import com.github.salomonbrys.kotson.jsonObject
|
||||
import com.google.gson.JsonElement
|
||||
import com.hiczp.bilibili.api.jsonParser
|
||||
import com.hiczp.bilibili.api.toPrettyPrintString
|
||||
import io.ktor.http.cio.websocket.Frame
|
||||
import io.ktor.http.cio.websocket.WebSocketSession
|
||||
import io.ktor.http.cio.websocket.readBytes
|
||||
import io.ktor.util.InternalAPI
|
||||
import io.ktor.util.moveToByteArray
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* 数据包模型
|
||||
* 由于 Android APP 并未全线换成 wss, 以下用的是移动版网页的协议
|
||||
* 数据包头部结构 00 00 00 65 00 10 00 01 00 00 00 07 00 00 00 01
|
||||
* |数据包总长度| |头长| |ver| |数据包类型 | | single |
|
||||
*
|
||||
* @param packetType 数据包类型
|
||||
* @param content 正文内容
|
||||
* @param protocolVersion 协议版本
|
||||
* @param sequence 序列号, 似乎总为 1
|
||||
* @param packetType 数据包类型
|
||||
* @param single 如果一个 Message 只有一个数据包则为 1, 否则为 0
|
||||
* @param content 正文内容
|
||||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
class Packet(
|
||||
val packetType: PacketType,
|
||||
val content: ByteArray,
|
||||
val protocolVersion: Short = 1,
|
||||
val sequence: Int = 1
|
||||
val packetType: PacketType,
|
||||
val single: Int = 1,
|
||||
val content: ByteBuffer
|
||||
) {
|
||||
constructor(
|
||||
packetType: PacketType,
|
||||
content: JsonElement,
|
||||
protocolVersion: Short = 1,
|
||||
sequence: Int = 1
|
||||
) : this(packetType, content.toString().toByteArray(), protocolVersion, sequence)
|
||||
val totalLength
|
||||
get() = headerLength + content.limit()
|
||||
|
||||
val totalLength: Int
|
||||
get() = headerLength + content.size
|
||||
val headerLength: Short = 0x10
|
||||
|
||||
val headerLength: Short = 16
|
||||
|
||||
fun getJsonContent() = jsonParser.parse(content.toString(Charsets.UTF_8))!!
|
||||
|
||||
fun toFrame() = Frame.Binary(
|
||||
true,
|
||||
fun toByteBuffer() =
|
||||
ByteBuffer.allocate(totalLength)
|
||||
.putInt(totalLength)
|
||||
.putShort(headerLength)
|
||||
.putShort(protocolVersion)
|
||||
.putInt(packetType.value)
|
||||
.putInt(sequence)
|
||||
.put(content)
|
||||
.flip()
|
||||
.putInt(single)
|
||||
.put(content).apply {
|
||||
flip()
|
||||
}!!
|
||||
|
||||
fun toFrame() = Frame.Binary(
|
||||
true,
|
||||
toByteBuffer()
|
||||
)
|
||||
|
||||
//for debug
|
||||
override fun toString() = toFrame().readBytes().toPrettyPrintString()
|
||||
|
||||
companion object {
|
||||
@UseExperimental(InternalAPI::class)
|
||||
fun fromFrame(frame: Frame) =
|
||||
with(frame.buffer) {
|
||||
int
|
||||
short
|
||||
val protocolVersion = short
|
||||
val packetType = PacketType.getByValue(int)
|
||||
val sequence = int
|
||||
val content = moveToByteArray()
|
||||
Packet(packetType, content, protocolVersion, sequence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class PacketType(val value: Int) {
|
||||
//impossible
|
||||
UNKNOWN(0),
|
||||
|
||||
HEARTBEAT(2),
|
||||
|
||||
POPULARITY(3),
|
||||
|
||||
COMMAND(5),
|
||||
|
||||
ENTER_ROOM(7),
|
||||
|
||||
ENTER_ROOM_RESPONSE(8);
|
||||
|
||||
companion object {
|
||||
fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 预设数据包
|
||||
* 一个 Message 中可能包含多个数据包
|
||||
*/
|
||||
object PresetPacket {
|
||||
/**
|
||||
* 进房数据包
|
||||
* {"uid":50333369,"roomid":14073662,"protover":0}
|
||||
*
|
||||
* @param anchorUserId 房间主的用户 ID
|
||||
* @param roomId 房间号
|
||||
*/
|
||||
@Suppress("SpellCheckingInspection")
|
||||
fun enterRoomPacket(anchorUserId: Long, roomId: Long) = Packet(
|
||||
PacketType.ENTER_ROOM,
|
||||
jsonObject(
|
||||
"uid" to anchorUserId,
|
||||
"roomid" to roomId,
|
||||
"protover" to 0 //该值总为 0
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 心跳包
|
||||
* 心跳包的正文内容可能是故意的, 为固定值 [object Object]
|
||||
*/
|
||||
fun heartbeatPacket(content: ByteArray = "[object Object]".toByteArray()) = Packet(
|
||||
PacketType.HEARTBEAT,
|
||||
content
|
||||
)
|
||||
internal fun Frame.toPackets(): List<Packet> {
|
||||
val bufferLength = buffer.limit()
|
||||
val list = ArrayList<Packet>()
|
||||
while (buffer.hasRemaining()) {
|
||||
val startPosition = buffer.position()
|
||||
val totalLength = buffer.int
|
||||
buffer.position(buffer.position() + 2) //skip headerLength
|
||||
val protocolVersion = buffer.short
|
||||
val packetType = PacketType.getByValue(buffer.int)
|
||||
val sequence = buffer.int
|
||||
buffer.limit(startPosition + totalLength)
|
||||
val content = buffer.slice()
|
||||
buffer.position(buffer.limit())
|
||||
buffer.limit(bufferLength)
|
||||
list.add(Packet(protocolVersion, packetType, sequence, content))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
internal fun Frame.toPacket() = Packet.fromFrame(this)
|
||||
|
||||
internal suspend inline fun WebSocketSession.send(packet: Packet) = send(packet.toFrame())
|
||||
|
@ -0,0 +1,20 @@
|
||||
package com.hiczp.bilibili.api.live.websocket
|
||||
|
||||
enum class PacketType(val value: Int) {
|
||||
//impossible
|
||||
UNKNOWN(0),
|
||||
|
||||
HEARTBEAT(2),
|
||||
|
||||
POPULARITY(3),
|
||||
|
||||
COMMAND(5),
|
||||
|
||||
ENTER_ROOM(7),
|
||||
|
||||
ENTER_ROOM_RESPONSE(8);
|
||||
|
||||
companion object {
|
||||
fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
161
src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Parser.kt
Normal file
161
src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Parser.kt
Normal file
@ -0,0 +1,161 @@
|
||||
package com.hiczp.bilibili.api.live.websocket
|
||||
|
||||
import com.github.salomonbrys.kotson.*
|
||||
import com.google.gson.JsonArray
|
||||
import com.google.gson.JsonObject
|
||||
import com.hiczp.bilibili.api.isEmpty
|
||||
|
||||
/**
|
||||
* 用于解析 DANMU_MSG 的工具类
|
||||
* 注意, 并非所有字段的含义都已明确. 例如 bubble, guardLevel, teamid
|
||||
*
|
||||
* @see com.hiczp.bilibili.api.live.model.RoomMessage
|
||||
*/
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
inline class DanmakuMessage(val data: JsonObject) {
|
||||
inline val info: JsonArray
|
||||
get() = data.get("info").array
|
||||
|
||||
inline val basicInfo
|
||||
get() = info[0].array
|
||||
|
||||
/**
|
||||
* 弹幕池
|
||||
*/
|
||||
inline val pool
|
||||
get() = basicInfo[0].int
|
||||
|
||||
/**
|
||||
* 弹幕类型, 可能和视频弹幕一致
|
||||
* (1从右至左滚动弹幕|6从左至右滚动弹幕|5顶端固定弹幕|4底端固定弹幕|7高级弹幕|8脚本弹幕)
|
||||
*/
|
||||
inline val type
|
||||
get() = basicInfo[1].int
|
||||
|
||||
/**
|
||||
* 弹幕字号
|
||||
*/
|
||||
inline val fontSize
|
||||
get() = basicInfo[2].int
|
||||
|
||||
/**
|
||||
* 弹幕颜色
|
||||
*/
|
||||
inline val color
|
||||
get() = basicInfo[3].int
|
||||
|
||||
/**
|
||||
* 弹幕发送时间
|
||||
*/
|
||||
inline val timestamp
|
||||
get() = basicInfo[4].long
|
||||
|
||||
/**
|
||||
* 发送此弹幕的客户端进入直播间的时间.
|
||||
* 注意, 如果弹幕来自一个 Android 客户端, 那么此字段是一个随机数(不包括符号位有9位或者10位), 可能为负数
|
||||
*/
|
||||
inline val enterRoomTime
|
||||
get() = basicInfo[5].long
|
||||
|
||||
/**
|
||||
* 用户 ID 的 CRC32 校验和
|
||||
* 注意, 不需要用此字段来得到用户 ID
|
||||
*/
|
||||
inline val userIdCrc32
|
||||
get() = basicInfo[7].string
|
||||
|
||||
/**
|
||||
* 弹幕的内容
|
||||
*/
|
||||
inline val message
|
||||
get() = info[1].string
|
||||
|
||||
inline val userInfo
|
||||
get() = info[2].array
|
||||
|
||||
inline val userId
|
||||
get() = userInfo[0].long
|
||||
|
||||
inline val nickname
|
||||
get() = userInfo[1].string
|
||||
|
||||
inline val isAdmin
|
||||
get() = userInfo[2].int
|
||||
|
||||
inline val isVip
|
||||
get() = userInfo[3].int
|
||||
|
||||
inline val isSVip
|
||||
get() = userInfo[4].int
|
||||
|
||||
/**
|
||||
* 粉丝勋章信息
|
||||
* 注意, 如果弹幕发送者没有佩戴勋章则该字段为一个空 JsonArray
|
||||
* 未佩戴粉丝勋章时, 下面几个字段都会返回 null
|
||||
*/
|
||||
inline val fansMedalInfo
|
||||
get() = info[3].array
|
||||
|
||||
inline val fansMedalLevel
|
||||
get() = if (fansMedalInfo.isEmpty()) null else fansMedalInfo[0].int
|
||||
|
||||
inline val fansMedalName
|
||||
get() = if (fansMedalInfo.isEmpty()) null else fansMedalInfo[1].string
|
||||
|
||||
/**
|
||||
* 粉丝勋章对应的主播的用户名
|
||||
*/
|
||||
inline val fansMedalAnchorNickname
|
||||
get() = if (fansMedalInfo.isEmpty()) null else fansMedalInfo[2].string
|
||||
|
||||
/**
|
||||
* 粉丝勋章对应的主播的直播间号码
|
||||
*/
|
||||
inline val fansMedalAnchorRoomId
|
||||
get() = if (fansMedalInfo.isEmpty()) null else fansMedalInfo[3].long
|
||||
|
||||
/**
|
||||
* 粉丝勋章的背景颜色
|
||||
*/
|
||||
inline val fansMedalBackgroundColor
|
||||
get() = if (fansMedalInfo.isEmpty()) null else fansMedalInfo[4].int
|
||||
|
||||
inline val userLevelInfo
|
||||
get() = info[4].array
|
||||
|
||||
/**
|
||||
* UL, 发送者的用户等级, 非主播等级
|
||||
*/
|
||||
inline val userLevel
|
||||
get() = userLevelInfo[0].int
|
||||
|
||||
/**
|
||||
* 用户等级标识的边框的颜色, 通常为最后一个佩戴的粉丝勋章的颜色
|
||||
*/
|
||||
inline val userLevelBorderColor
|
||||
get() = userLevelInfo[2].int
|
||||
|
||||
/**
|
||||
* 用户排名, 可能为数字, 也可能是 ">50000"
|
||||
*/
|
||||
inline val userRank
|
||||
get() = userLevelInfo[3].string
|
||||
|
||||
/**
|
||||
* 用户头衔
|
||||
* 可能为空列表, 也可能是值为 "" 的列表
|
||||
* 可能有两项, 两项的值可能一样
|
||||
*/
|
||||
inline val userTitles
|
||||
get() = info[5].array.map { it.string }
|
||||
|
||||
/**
|
||||
* 校验信息
|
||||
* {
|
||||
* "ts": 1553368447,
|
||||
* "ct": "98688F2F"
|
||||
* }
|
||||
*/
|
||||
inline val checkInfo
|
||||
get() = info[9].obj
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
package com.hiczp.bilibili.api.live.websocket
|
||||
|
||||
import com.github.salomonbrys.kotson.jsonObject
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/**
|
||||
* 预设数据包
|
||||
*/
|
||||
object PresetPacket {
|
||||
/**
|
||||
* 进房数据包
|
||||
* {"uid":50333369,"roomid":14073662,"protover":0}
|
||||
*
|
||||
* @param anchorUserId 房间主的用户 ID
|
||||
* @param roomId 房间号
|
||||
*/
|
||||
@Suppress("SpellCheckingInspection")
|
||||
fun enterRoomPacket(anchorUserId: Long, roomId: Long) = Packet(
|
||||
packetType = PacketType.ENTER_ROOM,
|
||||
content = ByteBuffer.wrap(
|
||||
jsonObject(
|
||||
"uid" to anchorUserId,
|
||||
"roomid" to roomId,
|
||||
"protover" to 0 //该值总为 0
|
||||
).toString().toByteArray()
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* 心跳包
|
||||
* 心跳包的正文内容可能是故意的, 为固定值 [object Object]
|
||||
*/
|
||||
fun heartbeatPacket(content: ByteBuffer = ByteBuffer.wrap("[object Object]".toByteArray())) = Packet(
|
||||
packetType = PacketType.HEARTBEAT,
|
||||
content = content
|
||||
)
|
||||
}
|
@ -1,13 +1,49 @@
|
||||
package com.hiczp.bilibili.api.test
|
||||
|
||||
import com.github.salomonbrys.kotson.byString
|
||||
import com.hiczp.bilibili.api.isNotEmpty
|
||||
import com.hiczp.bilibili.api.live.websocket.DanmakuMessage
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Paths
|
||||
|
||||
class LiveClientTest {
|
||||
@Test
|
||||
fun liveClient() {
|
||||
val path = Paths.get("record/直播弹幕/").also {
|
||||
it.toFile().mkdirs()
|
||||
}
|
||||
|
||||
runBlocking {
|
||||
bilibiliClient.liveClient(roomId = 3).start()
|
||||
bilibiliClient.liveClient(
|
||||
roomId = 3,
|
||||
onConnect = {
|
||||
println("Connected")
|
||||
},
|
||||
onPopularityPacket = { _, popularity ->
|
||||
println("Current popularity: $popularity")
|
||||
},
|
||||
onCommandPacket = { _, jsonObject ->
|
||||
val json = jsonObject.toString()
|
||||
val cmd by jsonObject.byString
|
||||
path.resolve("$cmd.json").toFile().run {
|
||||
if (!exists()) writeText(json)
|
||||
}
|
||||
|
||||
println(
|
||||
if (cmd == "DANMU_MSG") {
|
||||
with(DanmakuMessage(jsonObject)) {
|
||||
"${if (fansMedalInfo.isNotEmpty()) "[$fansMedalName $fansMedalLevel] " else ""}[UL$userLevel] $nickname: $message"
|
||||
}
|
||||
} else {
|
||||
json
|
||||
}
|
||||
)
|
||||
},
|
||||
onClose = { _, closeReason ->
|
||||
println(closeReason)
|
||||
}
|
||||
).start()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user