From f2984bae0ad08155379f11c9452e3e0bdc956355 Mon Sep 17 00:00:00 2001 From: czp3009 Date: Sun, 24 Mar 2019 23:24:32 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=E7=9B=B4=E6=92=AD=E9=97=B4?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E5=BC=B9=E5=B9=95=E7=9A=84=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 91 ++++++++++ build.gradle | 2 +- record/直播弹幕/COMBO_END.json | 14 ++ record/直播弹幕/COMBO_SEND.json | 12 ++ record/直播弹幕/DANMU_MSG.json | 54 ++++++ record/直播弹幕/ENTRY_EFFECT.json | 22 +++ record/直播弹幕/GUARD_BUY.json | 14 ++ record/直播弹幕/GUARD_LOTTERY_START.json | 27 +++ record/直播弹幕/GUARD_MSG.json | 9 + record/直播弹幕/NOTICE_MSG.json | 37 ++++ record/直播弹幕/ROOM_BLOCK_MSG.json | 11 ++ record/直播弹幕/ROOM_RANK.json | 11 ++ .../ROOM_REAL_TIME_MESSAGE_UPDATE.json | 7 + record/直播弹幕/ROOM_SILENT_ON.json | 9 + record/直播弹幕/SEND_GIFT.json | 44 +++++ record/直播弹幕/SYS_MSG.json | 14 ++ record/直播弹幕/USER_TOAST_MSG.json | 10 ++ record/直播弹幕/WELCOME.json | 9 + record/直播弹幕/WELCOME_GUARD.json | 8 + .../com/hiczp/bilibili/api/BilibiliClient.kt | 13 +- .../com/hiczp/bilibili/api/GsonExtension.kt | 9 + .../com/hiczp/bilibili/api/IOExtension.kt | 2 +- .../bilibili/api/live/websocket/LiveClient.kt | 102 +++++++---- .../bilibili/api/live/websocket/Packet.kt | 129 +++++--------- .../bilibili/api/live/websocket/PacketType.kt | 20 +++ .../bilibili/api/live/websocket/Parser.kt | 161 ++++++++++++++++++ .../api/live/websocket/PresetPacket.kt | 37 ++++ .../hiczp/bilibili/api/test/LiveClientTest.kt | 38 ++++- 28 files changed, 791 insertions(+), 125 deletions(-) create mode 100644 record/直播弹幕/COMBO_END.json create mode 100644 record/直播弹幕/COMBO_SEND.json create mode 100644 record/直播弹幕/DANMU_MSG.json create mode 100644 record/直播弹幕/ENTRY_EFFECT.json create mode 100644 record/直播弹幕/GUARD_BUY.json create mode 100644 record/直播弹幕/GUARD_LOTTERY_START.json create mode 100644 record/直播弹幕/GUARD_MSG.json create mode 100644 record/直播弹幕/NOTICE_MSG.json create mode 100644 record/直播弹幕/ROOM_BLOCK_MSG.json create mode 100644 record/直播弹幕/ROOM_RANK.json create mode 100644 record/直播弹幕/ROOM_REAL_TIME_MESSAGE_UPDATE.json create mode 100644 record/直播弹幕/ROOM_SILENT_ON.json create mode 100644 record/直播弹幕/SEND_GIFT.json create mode 100644 record/直播弹幕/SYS_MSG.json create mode 100644 record/直播弹幕/USER_TOAST_MSG.json create mode 100644 record/直播弹幕/WELCOME.json create mode 100644 record/直播弹幕/WELCOME_GUARD.json create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/GsonExtension.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/live/websocket/PacketType.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Parser.kt create mode 100644 src/main/kotlin/com/hiczp/bilibili/api/live/websocket/PresetPacket.kt diff --git a/README.md b/README.md index c285d91..4a8c19e 100644 --- a/README.md +++ b/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 diff --git a/build.gradle b/build.gradle index 9a6c082..6779002 100644 --- a/build.gradle +++ b/build.gradle @@ -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 { diff --git a/record/直播弹幕/COMBO_END.json b/record/直播弹幕/COMBO_END.json new file mode 100644 index 0000000..99998c8 --- /dev/null +++ b/record/直播弹幕/COMBO_END.json @@ -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 + } +} \ No newline at end of file diff --git a/record/直播弹幕/COMBO_SEND.json b/record/直播弹幕/COMBO_SEND.json new file mode 100644 index 0000000..1d62672 --- /dev/null +++ b/record/直播弹幕/COMBO_SEND.json @@ -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" + } +} \ No newline at end of file diff --git a/record/直播弹幕/DANMU_MSG.json b/record/直播弹幕/DANMU_MSG.json new file mode 100644 index 0000000..f5e71f6 --- /dev/null +++ b/record/直播弹幕/DANMU_MSG.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/record/直播弹幕/ENTRY_EFFECT.json b/record/直播弹幕/ENTRY_EFFECT.json new file mode 100644 index 0000000..7f1b44b --- /dev/null +++ b/record/直播弹幕/ENTRY_EFFECT.json @@ -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 + } +} \ No newline at end of file diff --git a/record/直播弹幕/GUARD_BUY.json b/record/直播弹幕/GUARD_BUY.json new file mode 100644 index 0000000..0e2bc98 --- /dev/null +++ b/record/直播弹幕/GUARD_BUY.json @@ -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 + } +} \ No newline at end of file diff --git a/record/直播弹幕/GUARD_LOTTERY_START.json b/record/直播弹幕/GUARD_LOTTERY_START.json new file mode 100644 index 0000000..285d2cf --- /dev/null +++ b/record/直播弹幕/GUARD_LOTTERY_START.json @@ -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": "" + } + } +} \ No newline at end of file diff --git a/record/直播弹幕/GUARD_MSG.json b/record/直播弹幕/GUARD_MSG.json new file mode 100644 index 0000000..e46ed06 --- /dev/null +++ b/record/直播弹幕/GUARD_MSG.json @@ -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 +} \ No newline at end of file diff --git a/record/直播弹幕/NOTICE_MSG.json b/record/直播弹幕/NOTICE_MSG.json new file mode 100644 index 0000000..502bd92 --- /dev/null +++ b/record/直播弹幕/NOTICE_MSG.json @@ -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 +} \ No newline at end of file diff --git a/record/直播弹幕/ROOM_BLOCK_MSG.json b/record/直播弹幕/ROOM_BLOCK_MSG.json new file mode 100644 index 0000000..5236cb0 --- /dev/null +++ b/record/直播弹幕/ROOM_BLOCK_MSG.json @@ -0,0 +1,11 @@ +{ + "cmd": "ROOM_BLOCK_MSG", + "uid": 8305711, + "uname": "RMT0v0", + "data": { + "uid": 8305711, + "uname": "RMT0v0", + "operator": 1 + }, + "roomid": 1029 +} \ No newline at end of file diff --git a/record/直播弹幕/ROOM_RANK.json b/record/直播弹幕/ROOM_RANK.json new file mode 100644 index 0000000..d3768c4 --- /dev/null +++ b/record/直播弹幕/ROOM_RANK.json @@ -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 + } +} \ No newline at end of file diff --git a/record/直播弹幕/ROOM_REAL_TIME_MESSAGE_UPDATE.json b/record/直播弹幕/ROOM_REAL_TIME_MESSAGE_UPDATE.json new file mode 100644 index 0000000..bcfcf5b --- /dev/null +++ b/record/直播弹幕/ROOM_REAL_TIME_MESSAGE_UPDATE.json @@ -0,0 +1,7 @@ +{ + "cmd": "ROOM_REAL_TIME_MESSAGE_UPDATE", + "data": { + "roomid": 23058, + "fans": 300958 + } +} \ No newline at end of file diff --git a/record/直播弹幕/ROOM_SILENT_ON.json b/record/直播弹幕/ROOM_SILENT_ON.json new file mode 100644 index 0000000..acfa7ce --- /dev/null +++ b/record/直播弹幕/ROOM_SILENT_ON.json @@ -0,0 +1,9 @@ +{ + "cmd": "ROOM_SILENT_ON", + "data": { + "type": "level", + "level": 20, + "second": -1 + }, + "roomid": 1029 +} \ No newline at end of file diff --git a/record/直播弹幕/SEND_GIFT.json b/record/直播弹幕/SEND_GIFT.json new file mode 100644 index 0000000..7d530bf --- /dev/null +++ b/record/直播弹幕/SEND_GIFT.json @@ -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 + } +} \ No newline at end of file diff --git a/record/直播弹幕/SYS_MSG.json b/record/直播弹幕/SYS_MSG.json new file mode 100644 index 0000000..03fd406 --- /dev/null +++ b/record/直播弹幕/SYS_MSG.json @@ -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 +} \ No newline at end of file diff --git a/record/直播弹幕/USER_TOAST_MSG.json b/record/直播弹幕/USER_TOAST_MSG.json new file mode 100644 index 0000000..d90fee2 --- /dev/null +++ b/record/直播弹幕/USER_TOAST_MSG.json @@ -0,0 +1,10 @@ +{ + "cmd": "USER_TOAST_MSG", + "data": { + "op_type": 1, + "uid": 1781654, + "username": "renbye", + "guard_level": 3, + "is_show": 0 + } +} \ No newline at end of file diff --git a/record/直播弹幕/WELCOME.json b/record/直播弹幕/WELCOME.json new file mode 100644 index 0000000..1835122 --- /dev/null +++ b/record/直播弹幕/WELCOME.json @@ -0,0 +1,9 @@ +{ + "cmd": "WELCOME", + "data": { + "uid": 3173595, + "uname": "百杜Paido", + "is_admin": false, + "svip": 1 + } +} \ No newline at end of file diff --git a/record/直播弹幕/WELCOME_GUARD.json b/record/直播弹幕/WELCOME_GUARD.json new file mode 100644 index 0000000..b1e439a --- /dev/null +++ b/record/直播弹幕/WELCOME_GUARD.json @@ -0,0 +1,8 @@ +{ + "cmd": "WELCOME_GUARD", + "data": { + "uid": 3007159, + "username": "goodbyecaroline", + "guard_level": 3 + } +} \ No newline at end of file diff --git a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt index f2f6078..010606d 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt @@ -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 + ) /** * 登陆 diff --git a/src/main/kotlin/com/hiczp/bilibili/api/GsonExtension.kt b/src/main/kotlin/com/hiczp/bilibili/api/GsonExtension.kt new file mode 100644 index 0000000..e9a26c4 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/GsonExtension.kt @@ -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 diff --git a/src/main/kotlin/com/hiczp/bilibili/api/IOExtension.kt b/src/main/kotlin/com/hiczp/bilibili/api/IOExtension.kt index 0bb4771..34b6ed0 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/IOExtension.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/IOExtension.kt @@ -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) } diff --git a/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/LiveClient.kt b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/LiveClient.kt index 8b764ff..b70a2fc 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/LiveClient.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/LiveClient.kt @@ -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 } diff --git a/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Packet.kt b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Packet.kt index 0e25acd..e31cdf1 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Packet.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Packet.kt @@ -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 { + val bufferLength = buffer.limit() + val list = ArrayList() + 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()) diff --git a/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/PacketType.kt b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/PacketType.kt new file mode 100644 index 0000000..794e804 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/PacketType.kt @@ -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 + } +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Parser.kt b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Parser.kt new file mode 100644 index 0000000..d14e816 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/Parser.kt @@ -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 +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/PresetPacket.kt b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/PresetPacket.kt new file mode 100644 index 0000000..0a6c316 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/live/websocket/PresetPacket.kt @@ -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 + ) +} diff --git a/src/test/kotlin/com/hiczp/bilibili/api/test/LiveClientTest.kt b/src/test/kotlin/com/hiczp/bilibili/api/test/LiveClientTest.kt index b4a062e..86e5af9 100644 --- a/src/test/kotlin/com/hiczp/bilibili/api/test/LiveClientTest.kt +++ b/src/test/kotlin/com/hiczp/bilibili/api/test/LiveClientTest.kt @@ -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() } } }