mirror of
https://github.com/czp3009/bilibili-api.git
synced 2026-08-13 22:43:29 +08:00
实现直播弹幕发送
This commit is contained in:
25
README.md
25
README.md
@@ -364,7 +364,7 @@ Crc32Cracker
|
|||||||
|
|
||||||
番剧的弹幕同理.
|
番剧的弹幕同理.
|
||||||
|
|
||||||
## 发送弹幕
|
## 发送视频弹幕
|
||||||
光看不发憋着慌, 我们来发送一条视频弹幕:
|
光看不发憋着慌, 我们来发送一条视频弹幕:
|
||||||
|
|
||||||
```kotlin
|
```kotlin
|
||||||
@@ -472,5 +472,28 @@ onClose = { liveClient, closeReason ->
|
|||||||
|
|
||||||
如果数据包被中间人修改, 那么可能不会触发 `onConnect` 回调, 但是会触发 `onClose`.
|
如果数据包被中间人修改, 那么可能不会触发 `onConnect` 回调, 但是会触发 `onClose`.
|
||||||
|
|
||||||
|
如果手动取消了执行 `start()` 方法的协程将不会触发 `onClose`.
|
||||||
|
|
||||||
|
## 发送直播弹幕
|
||||||
|
在直播间里发送弹幕也非常简单(必须先登陆)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
liveClient.sendMessage("我上我也行").await()
|
||||||
|
```
|
||||||
|
|
||||||
|
注意, 除了弹幕超长(普通用户为 20 个 Unicode 字符, 老爷, 会员可以额外加长)会导致抛出异常, 其他情况都会正常返回.
|
||||||
|
|
||||||
|
完全正常返回时, 返回内容中的 `message` 为一个空字符串.
|
||||||
|
|
||||||
|
如果不为空字符串, 则表示不完全正常
|
||||||
|
|
||||||
|
例如返回内容的 `message` 为 "msg repeat" 则表示短时间重复发送相同的弹幕而被服务器拒绝, 但是返回的 `code` 为 0.
|
||||||
|
|
||||||
|
其他情况诸如包含特殊字符, 包含不文明词语等均会导致不完全正常的返回.
|
||||||
|
|
||||||
|
正常返回时, 客户端都会将这条弹幕显示到屏幕上, 如果不是完全正常的, 那么这条弹幕就只有自己能看见(刷新后也会消失).
|
||||||
|
|
||||||
|
所以请额外判断返回的 `message` 是否为空字符串.
|
||||||
|
|
||||||
# License
|
# License
|
||||||
GPL V3
|
GPL V3
|
||||||
|
|||||||
@@ -248,12 +248,13 @@ class BilibiliClient(
|
|||||||
fetchRoomId: Boolean = true,
|
fetchRoomId: Boolean = true,
|
||||||
fetchDanmakuConfig: Boolean = true,
|
fetchDanmakuConfig: Boolean = true,
|
||||||
doEntryRoomAction: Boolean = false,
|
doEntryRoomAction: Boolean = false,
|
||||||
|
sendUserOnlineHeart: Boolean = false,
|
||||||
onConnect: (LiveClient) -> Unit,
|
onConnect: (LiveClient) -> Unit,
|
||||||
onPopularityPacket: (LiveClient, Int) -> Unit,
|
onPopularityPacket: (LiveClient, Int) -> Unit,
|
||||||
onCommandPacket: (LiveClient, JsonObject) -> Unit,
|
onCommandPacket: (LiveClient, JsonObject) -> Unit,
|
||||||
onClose: (LiveClient, CloseReason?) -> Unit
|
onClose: (LiveClient, CloseReason?) -> Unit
|
||||||
) = LiveClient(
|
) = LiveClient(
|
||||||
this, roomId, fetchRoomId, fetchDanmakuConfig, doEntryRoomAction,
|
this, roomId, fetchRoomId, fetchDanmakuConfig, doEntryRoomAction, sendUserOnlineHeart,
|
||||||
onConnect, onPopularityPacket, onCommandPacket, onClose
|
onConnect, onPopularityPacket, onCommandPacket, onClose
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ data class Danmaku(
|
|||||||
val time: Long,
|
val time: Long,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 弹幕类型
|
* 弹幕模式
|
||||||
* (1从右至左滚动弹幕|6从左至右滚动弹幕|5顶端固定弹幕|4底端固定弹幕|7高级弹幕|8脚本弹幕)
|
* (1从右至左滚动弹幕|6从左至右滚动弹幕|5顶端固定弹幕|4底端固定弹幕|7高级弹幕|8脚本弹幕)
|
||||||
*/
|
*/
|
||||||
val type: Int,
|
val mode: Int,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 字号
|
* 字号
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.hiczp.bilibili.api.live
|
|||||||
|
|
||||||
import com.hiczp.bilibili.api.live.model.*
|
import com.hiczp.bilibili.api.live.model.*
|
||||||
import com.hiczp.bilibili.api.retrofit.CommonResponse
|
import com.hiczp.bilibili.api.retrofit.CommonResponse
|
||||||
|
import com.hiczp.bilibili.api.retrofit.Header
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import retrofit2.http.*
|
import retrofit2.http.*
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直播站 API
|
* 直播站 API
|
||||||
@@ -183,4 +185,49 @@ interface LiveAPI {
|
|||||||
@Query("page_size") pageSize: Int = 30,
|
@Query("page_size") pageSize: Int = 30,
|
||||||
@Query("sort_type") sortType: String? = null
|
@Query("sort_type") sortType: String? = null
|
||||||
): Deferred<RoomList>
|
): Deferred<RoomList>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送弹幕(直播)
|
||||||
|
*
|
||||||
|
* @param bubble 气泡, 不明确含义
|
||||||
|
* @param cid 房间号
|
||||||
|
* @param mid 发送者的用户 ID
|
||||||
|
* @param message 弹幕内容
|
||||||
|
* @param random 随机数, 不包括符号位有 9 位 或者 10 位
|
||||||
|
* @param mode 弹幕模式, 可能与视频弹幕的模式含义相同, 可能需要特殊身份才能使用额外模式, 下同
|
||||||
|
* @param pool 弹幕池
|
||||||
|
* @param type 固定为 "json"
|
||||||
|
* @param color 弹幕颜色
|
||||||
|
* @param fontSize 弹幕字号
|
||||||
|
* @param playTime 不明确
|
||||||
|
*/
|
||||||
|
@Suppress("SpellCheckingInspection")
|
||||||
|
@POST("/api/sendmsg")
|
||||||
|
@FormUrlEncoded
|
||||||
|
@Headers(Header.FORCE_QUERY)
|
||||||
|
fun sendMessage(
|
||||||
|
@Field("bubble") bubble: Int = 0,
|
||||||
|
@Field("cid") cid: Long,
|
||||||
|
@Field("mid") mid: Long,
|
||||||
|
@Field("msg") message: String,
|
||||||
|
@Field("rnd") random: Int = (if (Random.nextBoolean()) 1 else -1) * Random.nextInt(100000000, Int.MAX_VALUE),
|
||||||
|
@Field("mode") mode: Int = 1,
|
||||||
|
@Field("pool") pool: Int = 0,
|
||||||
|
@Field("type") type: String = "json",
|
||||||
|
@Field("color") color: Int = 16777215,
|
||||||
|
@Field("fontsize") fontSize: Int = 25,
|
||||||
|
@Field("playTime") playTime: Float = 0.0f
|
||||||
|
): Deferred<CommonResponse>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于确认客户端在看直播的心跳包(与弹幕推送无关)
|
||||||
|
* 每五分钟发送一次
|
||||||
|
*/
|
||||||
|
@POST("/mobile/userOnlineHeart")
|
||||||
|
@FormUrlEncoded
|
||||||
|
@Headers(Header.FORCE_QUERY)
|
||||||
|
fun userOnlineHeart(
|
||||||
|
@Field("room_id") roomId: Long,
|
||||||
|
@Field("scale") scale: String = "xxhdpi"
|
||||||
|
): Deferred<CommonResponse>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import io.ktor.util.InternalAPI
|
|||||||
import io.ktor.util.KtorExperimentalAPI
|
import io.ktor.util.KtorExperimentalAPI
|
||||||
import io.ktor.util.decodeString
|
import io.ktor.util.decodeString
|
||||||
import kotlinx.coroutines.ObsoleteCoroutinesApi
|
import kotlinx.coroutines.ObsoleteCoroutinesApi
|
||||||
|
import kotlinx.coroutines.cancelAndJoin
|
||||||
import kotlinx.coroutines.channels.consumeEach
|
import kotlinx.coroutines.channels.consumeEach
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -26,6 +27,7 @@ import kotlinx.io.errors.IOException
|
|||||||
* @param fetchRoomId 是否在连接前先获取房间号(长号)
|
* @param fetchRoomId 是否在连接前先获取房间号(长号)
|
||||||
* @param fetchDanmakuConfig 是否在连接前先获取弹幕推送服务器地址
|
* @param fetchDanmakuConfig 是否在连接前先获取弹幕推送服务器地址
|
||||||
* @param doEntryRoomAction 是否产生直播间观看历史记录
|
* @param doEntryRoomAction 是否产生直播间观看历史记录
|
||||||
|
* @param sendUserOnlineHeart 是否发送 rest 心跳包, 这会增加观看直播的时长, 用于服务端统计(与弹幕推送无关)
|
||||||
* @param onConnect 回调函数, 连接成功时触发
|
* @param onConnect 回调函数, 连接成功时触发
|
||||||
* @param onPopularityPacket 回调函数, 接收到人气值数据包时触发
|
* @param onPopularityPacket 回调函数, 接收到人气值数据包时触发
|
||||||
* @param onCommandPacket 回调函数, 接收到 Command 数据包时触发
|
* @param onCommandPacket 回调函数, 接收到 Command 数据包时触发
|
||||||
@@ -38,6 +40,7 @@ class LiveClient(
|
|||||||
private val fetchRoomId: Boolean = true,
|
private val fetchRoomId: Boolean = true,
|
||||||
private val fetchDanmakuConfig: Boolean = true,
|
private val fetchDanmakuConfig: Boolean = true,
|
||||||
private val doEntryRoomAction: Boolean = false,
|
private val doEntryRoomAction: Boolean = false,
|
||||||
|
private val sendUserOnlineHeart: Boolean = false,
|
||||||
private val onConnect: (LiveClient) -> Unit,
|
private val onConnect: (LiveClient) -> Unit,
|
||||||
private val onPopularityPacket: (LiveClient, Int) -> Unit,
|
private val onPopularityPacket: (LiveClient, Int) -> Unit,
|
||||||
private val onCommandPacket: (LiveClient, JsonObject) -> Unit,
|
private val onCommandPacket: (LiveClient, JsonObject) -> Unit,
|
||||||
@@ -50,7 +53,8 @@ class LiveClient(
|
|||||||
private set
|
private set
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开启连接, 注意此方法是 suspend 的
|
* 开启连接
|
||||||
|
* 注意此方法将 suspend 所在协程直到连接关闭
|
||||||
*/
|
*/
|
||||||
@UseExperimental(KtorExperimentalAPI::class, ObsoleteCoroutinesApi::class, InternalAPI::class)
|
@UseExperimental(KtorExperimentalAPI::class, ObsoleteCoroutinesApi::class, InternalAPI::class)
|
||||||
suspend fun start() {
|
suspend fun start() {
|
||||||
@@ -78,7 +82,7 @@ class LiveClient(
|
|||||||
|
|
||||||
//产生历史记录
|
//产生历史记录
|
||||||
@Suppress("DeferredResultUnused")
|
@Suppress("DeferredResultUnused")
|
||||||
if (doEntryRoomAction) liveAPI.roomEntryAction(roomId)
|
if (doEntryRoomAction && bilibiliClient.isLogin) liveAPI.roomEntryAction(roomId)
|
||||||
|
|
||||||
//开启 websocket
|
//开启 websocket
|
||||||
HttpClient(CIO).config { install(WebSockets) }.wss(host = host, port = port, path = "/sub") {
|
HttpClient(CIO).config { install(WebSockets) }.wss(host = host, port = port, path = "/sub") {
|
||||||
@@ -98,8 +102,22 @@ class LiveClient(
|
|||||||
close(IOException("Receive unreadable server response"))
|
close(IOException("Receive unreadable server response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
//发送心跳包
|
//发送 rest 心跳包
|
||||||
launch {
|
val restHeartBeatJob = if (sendUserOnlineHeart && bilibiliClient.isLogin) {
|
||||||
|
launch {
|
||||||
|
val scale = bilibiliClient.billingClientProperties.scale
|
||||||
|
while (true) {
|
||||||
|
@Suppress("DeferredResultUnused")
|
||||||
|
liveAPI.userOnlineHeart(roomId, scale)
|
||||||
|
delay(300_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
//发送 websocket 心跳包
|
||||||
|
val websocketHeartBeatJob = launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
send(PresetPacket.heartbeatPacket())
|
send(PresetPacket.heartbeatPacket())
|
||||||
delay(30_000)
|
delay(30_000)
|
||||||
@@ -120,6 +138,9 @@ class LiveClient(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
restHeartBeatJob?.cancelAndJoin()
|
||||||
|
websocketHeartBeatJob.cancelAndJoin()
|
||||||
|
|
||||||
launch {
|
launch {
|
||||||
val closeReason = closeReason.await()
|
val closeReason = closeReason.await()
|
||||||
try {
|
try {
|
||||||
@@ -138,4 +159,10 @@ class LiveClient(
|
|||||||
websocketSession = null
|
websocketSession = null
|
||||||
close(CloseReason(CloseReason.Codes.NORMAL, "user close"))
|
close(CloseReason(CloseReason.Codes.NORMAL, "user close"))
|
||||||
} ?: Unit
|
} ?: Unit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送弹幕
|
||||||
|
*/
|
||||||
|
fun sendMessage(message: String) =
|
||||||
|
liveAPI.sendMessage(cid = roomId, mid = bilibiliClient.userId ?: 0, message = message)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ inline class DanmakuMessage(val data: JsonObject) {
|
|||||||
get() = basicInfo[0].int
|
get() = basicInfo[0].int
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 弹幕类型, 可能和视频弹幕一致
|
* 弹幕模式, 可能和视频弹幕一致
|
||||||
* (1从右至左滚动弹幕|6从左至右滚动弹幕|5顶端固定弹幕|4底端固定弹幕|7高级弹幕|8脚本弹幕)
|
* (1从右至左滚动弹幕|6从左至右滚动弹幕|5顶端固定弹幕|4底端固定弹幕|7高级弹幕|8脚本弹幕)
|
||||||
*/
|
*/
|
||||||
inline val type
|
inline val mode
|
||||||
get() = basicInfo[1].int
|
get() = basicInfo[1].int
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class LiveClientTest {
|
|||||||
runBlocking {
|
runBlocking {
|
||||||
bilibiliClient.liveClient(
|
bilibiliClient.liveClient(
|
||||||
roomId = 3,
|
roomId = 3,
|
||||||
|
sendUserOnlineHeart = true,
|
||||||
onConnect = {
|
onConnect = {
|
||||||
println("Connected")
|
println("Connected")
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.hiczp.bilibili.api.test
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
|
||||||
|
class SendLiveMessageTest {
|
||||||
|
@Test
|
||||||
|
fun sendMessage() {
|
||||||
|
runBlocking {
|
||||||
|
bilibiliClient.liveAPI
|
||||||
|
.sendMessage(cid = 29434, mid = 20293030, message = "自动捧场机器人").await()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user