完成直播间实时弹幕的获取

This commit is contained in:
czp3009 2019-03-24 23:24:32 +08:00
parent fa3b1d974c
commit f2984bae0a
28 changed files with 791 additions and 125 deletions

View File

@ -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

View File

@ -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 {

View 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
}
}

View 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"
}
}

View 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"
}
]
}

View 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
}
}

View 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
}
}

View 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": ""
}
}
}

View 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
}

View 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
}

View File

@ -0,0 +1,11 @@
{
"cmd": "ROOM_BLOCK_MSG",
"uid": 8305711,
"uname": "RMT0v0",
"data": {
"uid": 8305711,
"uname": "RMT0v0",
"operator": 1
},
"roomid": 1029
}

View 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
}
}

View File

@ -0,0 +1,7 @@
{
"cmd": "ROOM_REAL_TIME_MESSAGE_UPDATE",
"data": {
"roomid": 23058,
"fans": 300958
}
}

View File

@ -0,0 +1,9 @@
{
"cmd": "ROOM_SILENT_ON",
"data": {
"type": "level",
"level": 20,
"second": -1
},
"roomid": 1029
}

View 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
}
}

View 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
}

View File

@ -0,0 +1,10 @@
{
"cmd": "USER_TOAST_MSG",
"data": {
"op_type": 1,
"uid": 1781654,
"username": "renbye",
"guard_level": 3,
"is_show": 0
}
}

View File

@ -0,0 +1,9 @@
{
"cmd": "WELCOME",
"data": {
"uid": 3173595,
"uname": "百杜Paido",
"is_admin": false,
"svip": 1
}
}

View File

@ -0,0 +1,8 @@
{
"cmd": "WELCOME_GUARD",
"data": {
"uid": 3007159,
"username": "goodbyecaroline",
"guard_level": 3
}
}

View File

@ -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
)
/**
* 登陆

View 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

View File

@ -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) }

View File

@ -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
}

View File

@ -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())

View File

@ -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
}
}

View 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
}

View File

@ -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
)
}

View File

@ -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()
}
}
}