更新接口

This commit is contained in:
aaa1115910
2022-11-27 13:58:47 +08:00
parent 9fc3c236d8
commit 027ac81d42
30 changed files with 1679 additions and 823 deletions

3
.idea/.gitignore generated vendored
View File

@ -8,4 +8,5 @@
/navEditor.xml
/assetWizardSettings.xml
/misc.xml
/compiler.xml
/compiler.xml
/inspectionProfiles/Project_Default.xml

View File

@ -81,6 +81,7 @@ dependencies {
implementation(libs.logging)
implementation(libs.material)
implementation(libs.slf4j.android.mvysny)
implementation(project(mapOf("path" to ":bili-api")))
androidTestImplementation(platform("${androidx.compose.bom.get()}"))
androidTestImplementation(androidx.compose.ui.test.junit4)
debugImplementation(androidx.compose.ui.test.manifest)

View File

@ -1,820 +0,0 @@
package dev.aaa1115910.bv
import android.util.Xml
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.compression.ContentEncoding
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsChannel
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import mu.KotlinLogging
import org.xmlpull.v1.XmlPullParser
object BiliApi {
private var endPoint: String = ""
private lateinit var client: HttpClient
private val logger = KotlinLogging.logger { }
init {
createClient()
}
private fun createClient() {
client = HttpClient(OkHttp) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
})
}
install(ContentEncoding) {
deflate(1.0F)
gzip(0.9F)
}
}
}
/**
* 获取热门视频列表
*/
suspend fun getPopularVideoData(
pageNumber: Int = 1,
pageSize: Int = 20
): PopularVideosResponse = client.get("https://api.bilibili.com/x/web-interface/popular") {
parameter("pn", pageNumber)
parameter("ps", pageSize)
}.body()
/**
* 获取视频详细信息
*/
suspend fun getVideoInfo(
av: Int? = null,
bv: String? = null
): VideoInfoResponse = client.get("https://api.bilibili.com/x/web-interface/view") {
parameter("aid", av)
parameter("bvid", bv)
}.body()
/**
* 获取视频流
*/
suspend fun getVideoPlayUrl(
av: Int? = null,
bv: Int? = null,
cid: Int,
qn: Int? = null,
fnval: Int? = null,
fnver: Int? = null,
fourk: Int? = 0,
session: String? = null,
otype: String = "json",
type: String = "",
platform: String = "oc"
): PlayUrlResponse = client.get("https://api.bilibili.com/x/player/playurl") {
parameter("avid", av)
parameter("bvid", bv)
parameter("cid", cid)
parameter("qn", qn)
parameter("fnval", fnval)
parameter("fnver", fnver)
parameter("fourk", fourk)
parameter("session", session)
parameter("otype", otype)
parameter("type", type)
parameter("platform", platform)
}.body()
suspend fun getDanmakuXml(
cid: Int
): DanmakuResponse {
val xmlChannel = client.get("https://api.bilibili.com/x/v1/dm/list.so") {
parameter("oid", cid)
}.bodyAsChannel()
val xml = Xml.newPullParser()
xml.setInput(xmlChannel.toInputStream().reader())
var chatserver = ""
var chatId = 0
var maxLimit = 0
var state = 0
var realName = 0
var source = ""
val data = mutableListOf<DanmakuData>()
var eventType = xml.eventType
while (eventType != XmlPullParser.END_DOCUMENT) {
when (eventType) {
XmlPullParser.START_TAG -> {
when (xml.name) {
"chatserver" -> {
xml.next()
chatserver = xml.text
}
"chatid" -> {
xml.next()
chatId = xml.text.toInt()
}
"maxlimit" -> {
xml.next()
maxLimit = xml.text.toInt()
}
"state" -> {
xml.next()
state = xml.text.toInt()
}
"real_name" -> {
xml.next()
realName = xml.text.toInt()
}
"source" -> {
xml.next()
source = xml.text
}
"d" -> {
val p = xml.getAttributeValue(0)
xml.next()
val text = xml.text
data.add(DanmakuData.fromString(p, text))
}
}
}
}
eventType = xml.next();
}
val response = DanmakuResponse(chatserver, chatId, maxLimit, state, realName, source, data)
return response
}
/*
suspend fun getDanmakuXml(
cid: Int
) :String{
val url="https://api.bilibili.com/x/v1/dm/list.so?oid=$cid"
val okhttp=OkHttpClient()
val call = okhttp.newCall(Request.Builder().url(url).build())
val body= call.execute().body!!.string()
val xmlFormat = XML {
unknownChildHandler = UnknownChildHandler { _, _, _, _, _ -> emptyList() }
}
val a = xmlFormat.decodeFromString(DanmakuResponse.serializer(), body)
return a.toString()
}
*/
}
fun main() {
runBlocking {
val data = BiliApi.getDanmakuXml(cid = 267714)
println(data)
}
}
@Serializable
data class PopularVideosResponse(
val code: Int,
val message: String,
val ttl: Int,
val data: PopularVideoData
)
@Serializable
data class VideoInfoResponse(
val code: Int,
val message: String,
val ttl: Int,
val data: VideoInfo
)
@Serializable
data class PopularVideoData(
val list: List<VideoInfo>,
@SerialName("no_more")
val noMore: Boolean
)
/**
* 视频详细信息
*
* @param bvid 稿件bvid
* @param aid 稿件avid
* @param videos 稿件分P总数 默认为1
* @param tid 分区tid
* @param tname 子分区名称
* @param copyright 视频类型 1原创 2转载
* @param pic 稿件封面图片url
* @param title 稿件标题
* @param pubdate 稿件发布时间 秒级时间戳
* @param ctime 用户投稿时间 秒级时间戳
* @param desc 视频简介
* @param state 视频状态
* @param duration 稿件总时长(所有分P) 单位为秒
* @param forward 撞车视频跳转avid 仅撞车视频存在此字段
* @param missionId 稿件参与的活动id
* @param redirectUrl 稿重定向url 仅番剧或影视视频存在此字段,用于番剧&影视的av/bv->ep
* @param rights 视频属性标志
* @param owner 视频UP主信息
* @param stat 视频状态数
* @param dynamic 视频同步发布的的动态的文字内容
* @param cid 视频1P cid
* @param dimension 视频1P分辨率
* @param premiere null
* @param teenageMode
* @param isChargeableSeason
* @param isStory
* @param noCache
* @param pages 视频分P列表
* @param subtitle 视频CC字幕信息
* @param staff 合作成员列表 非合作视频无此项
* @param isSeasonDisplay
* @param userGarb 用户装扮信息
* @param honorReply
* @param likeIcon
* @param shortLink
* @param shortLinkV2
* @param firstFrame
* @param pubLocation
* @param seasonType
* @param isOgv
* @param ogvInfo
*
*/
@Serializable
data class VideoInfo(
val bvid: String,
val aid: Int,
val videos: Int,
val tid: Int,
val tname: String,
val copyright: Int,
val pic: String,
val title: String,
val pubdate: Int,
val ctime: Int,
val desc: String,
val state: Int,
val duration: Int,
val forward: Int? = null,
@SerialName("mission_id")
val missionId: Int? = null,
@SerialName("redirect_url")
val redirectUrl: String? = null,
val rights: VideoRights,
val owner: VideoOwner,
val stat: VideoStat,
val dynamic: String,
val cid: Int,
val dimension: Dimension,
val premiere: String? = null,
@SerialName("teenage_mode")
val teenageMode: Int = 0,
@SerialName("is_chargeable_season")
val isChargeableSeason: Boolean = false,
@SerialName("is_story")
val isStory: Boolean = false,
@SerialName("no_cache")
val noCache: Boolean = false,
val pages: List<VideoPage> = emptyList(),
val subtitle: Subtitle? = null,
val staff: List<Staff> = emptyList(),
@SerialName("is_season_display")
val isSeasonDisplay: Boolean = false,
@SerialName("user_garb")
val userGarb: UserGarb? = null,
@SerialName("honor_reply")
val honorReply: HonorReply? = null,
@SerialName("like_icon")
val likeIcon: String? = null,
@SerialName("short_link")
val shortLink: String? = null,
@SerialName("short_link_v2")
val shortLinkV2: String? = null,
@SerialName("first_frame")
val firstFrame: String? = null,
@SerialName("pub_location")
val pubLocation: String? = null,
@SerialName("season_type")
val seasonType: Int? = null,
@SerialName("is_ogv")
val isOgv: Boolean = false,
@SerialName("ogv_info")
val ogvInfo: String? = null
)
/**
* 视频属性标志
*
* @param bp 是否允许承包
* @param elec 是否支持充电
* @param download 是否允许下载
* @param movie 是否电影
* @param pay 是否PGC付费
* @param hd5 是否有高码率
* @param noReprint 是否显示“禁止转载”标志
* @param autoplay 是否自动播放
* @param ugcPay 是否UGC付费
* @param isCooperation 是否为联合投稿
* @param ugcPayPreview
* @param noBackground
* @param cleanMode
* @param isSteinGate 是否为互动视频
* @param is360 是否为全景视频
* @param noShare
* @param arcPay
* @param payFreeWatch
*/
@Serializable
data class VideoRights(
val bp: Int,
val elec: Int,
val download: Int,
val movie: Int,
val pay: Int,
val hd5: Int,
@SerialName("no_reprint")
val noReprint: Int,
val autoplay: Int,
@SerialName("ugc_pay")
val ugcPay: Int,
@SerialName("is_cooperation")
val isCooperation: Int,
@SerialName("ugc_pay_preview")
val ugcPayPreview: Int,
@SerialName("no_background")
val noBackground: Int,
@SerialName("clean_mode")
val cleanMode: Int? = null,
@SerialName("is_stein_gate")
val isSteinGate: Int? = null,
@SerialName("is_360")
val is360: Int? = null,
@SerialName("no_share")
val noShare: Int? = null,
@SerialName("arc_pay")
val arcPay: Int,
@SerialName("pay_free_watch")
val payFreeWatch: Int? = null
)
/**
* 视频作者
*
* @param mid UP主mid
* @param name UP主昵称
* @param face UP主头像
*/
@Serializable
data class VideoOwner(
val mid: Long,
val name: String,
val face: String
)
/**
* 视频数据
*
* @param aid 稿件avid
* @param view 播放数
* @param danmaku 弹幕数
* @param reply 评论数
* @param favorite 收藏数
* @param coin 投币数
* @param share 分享数
* @param nowRank 当前排名
* @param hisRank 历史最高排行
* @param like 获赞数
* @param dislike 点踩数 恒为0
* @param evaluation 视频评分
* @param argueMsg 警告/争议提示信息
*/
@Serializable
data class VideoStat(
val aid: Int,
val view: Int,
val danmaku: Int,
val reply: Int,
val favorite: Int,
val coin: Int,
val share: Int,
@SerialName("now_rank")
val nowRank: Int,
@SerialName("his_rank")
val hisRank: Int,
val like: Int,
val dislike: Int,
val evaluation: String = "",
@SerialName("argue_msg")
val argueMsg: String = ""
)
/**
* 分辨率
*
* @param width 当前分P 宽度
* @param height 当前分P 高度
* @param rotate 是否将宽高对换 0正常,1对换
*/
@Serializable
data class Dimension(
val width: Int,
val height: Int,
val rotate: Int
)
/**
* 视频分P
*
* @param cid 分P cid
* @param page 分P序号 从1开始
* @param from 视频来源 vupload普通上传B站
* @param hunan芒果TV
* @param qq腾讯
* @param part 分P标题
* @param duration 分P持续时间 单位为秒
* @param vid 站外视频vid 仅站外视频有效
* @param weblink 站外视频跳转url 仅站外视频有效
* @param dimension 当前分P分辨率 部分较老视频无分辨率值
*/
@Serializable
data class VideoPage(
val cid: Int,
val page: Int,
val from: String,
val part: String,
val duration: Int,
val vid: String,
val weblink: String,
val dimension: Dimension
)
/**
* 字幕信息
* @param allowSubmit 是否允许提交字幕
* @param list 字幕列表
*/
@Serializable
data class Subtitle(
val allowSubmit: Boolean = false,
val list: List<SubtitleListItem> = emptyList()
)
/**
* 字幕列表项
*
* @param id 字幕id
* @param lan 字幕语言
* @param lanDoc 字幕语言名称
* @param isLock 是否锁定
* @param authorMid 字幕上传者mid
* @param subtitleUrl json格式字幕文件url
* @param author 字幕上传者信息
*/
@Serializable
data class SubtitleListItem(
val id: Long,
val lan: String,
@SerialName("lan_doc")
val lanDoc: String,
@SerialName("is_lock")
val isLock: Boolean,
@SerialName("author_mid")
val authorMid: Int? = null,
@SerialName("subtitle_url")
val subtitleUrl: String,
val author: SubtitleAuthor
)
/**
* 字幕作者
*
* @param mid 字幕上传者mid
* @param name 字幕上传者昵称
* @param sex 字幕上传者性别 男 女 保密
* @param face 字幕上传者头像url
* @param sign 字幕上传者签名
* @param rank 10000 作用尚不明确
* @param birthday 0 作用尚不明确
* @param isFakeAccount 0 作用尚不明确
* @param isDeleted 0 作用尚不明确
*/
@Serializable
data class SubtitleAuthor(
val mid: Int,
val name: String,
val sex: String,
val face: String,
val sign: String,
val rank: Int,
val birthday: Int,
@SerialName("is_fake_account")
val isFakeAccount: Int,
@SerialName("is_deleted")
val isDeleted: Int
)
/**
* 创作者个人信息
*
* @param mid 成员mid
* @param title 成员名称
* @param name 成员昵称
* @param face 成员头像url
* @param vip 成员大会员状态
* @param official 成员认证信息
* @param follower 成员粉丝数
*/
@Serializable
data class Staff(
val mid: Int,
val title: String,
val name: String,
val face: String,
val vip: Vip,
val official: Official,
val follower: Int
)
/**
* 会员信息
*
* @param type 成员会员类型 0无 1月会员 2年会员
* @param status 会员状态 0无 1
* @param themeType num 0
*/
@Serializable
data class Vip(
val type: Int,
val status: Int,
@SerialName("theme_type")
val themeType: Int
)
/**
* 认证信息
*
* @param role 成员认证级别 0无 1 2 7个人认证 3 4 5 6机构认证
* @param title 成员认证名 无为空
* @param desc 成员认证备注 无为空
* @param type 成员认证类型 -1无 0
*/
@Serializable
data class Official(
val role: Int,
val title: String,
val desc: String,
val type: Int
)
/**
* 用户头像挂件
*
* @param urlImageAniCut
*/
@Serializable
data class UserGarb(
@SerialName("url_image_ani_cut")
val urlImageAniCut: String
)
/**
* 推荐理由
*
* @param honor
*/
@Serializable
data class HonorReply(
val honor: List<HonorReplyItem> = emptyList()
)
/**
* 推荐信息
*
* @param aid 当前稿件aid
* @param type 2第?期每周必看 3全站排行榜最高第?名 4热门
* @param desc 描述
* @param weeklyRecommendNum
*/
@Serializable
data class HonorReplyItem(
val aid: Int,
val type: Int,
val desc: String,
@SerialName("weekly_recommend_num")
val weeklyRecommendNum: Int
)
@Serializable
enum class VideoQuality(val qn: Int, val displayName: String) {
Q260P(6, "240P 极速"),
Q360P(16, "360P 流畅"),
Q480P(32, "480P 清晰"),
Q720P(64, ""),
Q720P60(74, ""),
Q1080P(80, ""),
Q1080PPlus(112, ""),
Q1080P60(116, ""),
Q4K(120, ""),
HDR(125, ""),
Dolby(126, ""),
Q8K(127, "")
}
@Serializable
data class PlayUrlResponse(
val code: Int,
val message: String,
val ttl: Int,
val data: PlayUrlData? = null
)
/**
* @param from local 作用尚不明确
* @param result suee 作用尚不明确
* @param message 空 作用尚不明确
* @param quality 当前的视频分辨率代码 值含义见上表
* @param format 视频格式
* @param timeLength 视频长度(毫秒值) 单位为毫秒 不同分辨率/格式可能有略微差异
* @param acceptFormat 视频支持的全部格式 每项用,分隔
* @param acceptDescription 视频支持的分辨率列表
* @param acceptQuality 视频支持的分辨率代码列表 值含义见上表
* @param videoCodecId 默认选择视频流的编码id 见视频编码代码
* @param seekParam 固定值start 作用尚不明确
* @param seekType offsetdash、flv secondmp4 作用尚不明确
* @param durl 视频分段 注仅flv/mp4存在此项
* @param dash dash音视频流信息 注仅dash存在此项
* @param supportFormats 支持格式的详细信息
* @param high_format null
* @param lastPlayTime 上次播放进度 毫秒值
* @param lastPlayCid 上次播放分p的cid
*/
@Serializable
data class PlayUrlData(
val from: String,
val result: String,
val message: String,
val quality: Int,
val format: String,
@SerialName("timelength")
val timeLength: Int,
@SerialName("accept_format")
val acceptFormat: String,
@SerialName("accept_description")
val acceptDescription: List<String> = emptyList(),
@SerialName("accept_quality")
val acceptQuality: List<Int> = emptyList(),
@SerialName("video_codecid")
val videoCodecId: Int,
@SerialName("seek_param")
val seekParam: String,
@SerialName("seek_type")
val seekType: String,
val durl: List<Durl> = emptyList(),
val dash: Dash? = null,
@SerialName("support_formats")
val supportFormats: List<SupportFormat> = emptyList(),
@SerialName("last_play_time")
val lastPlayTime: Int,
@SerialName("last_play_cid")
val lastPlayCid: Int
)
/**
* 视频播放地址
*
* @param order 视频分段序号 某些视频会分为多个片段从1顺序增长
* @param length 视频长度 单位为毫秒
* @param size 视频大小 单位为Byte
* @param ahead 空 作用尚不明确
* @param vhead 空 作用尚不明确
* @param url 视频流url 注url内容存在转义符 有效时间为120min
* @param backupUrl 备用视频流
*/
@Serializable
data class Durl(
val order: Int,
val length: Int,
val size: Int,
val ahead: String,
val vhead: String,
val url: String,
@SerialName("backup_url")
val backupUrl: List<String> = emptyList()
)
//TODO
@Serializable
data class Dash(
val duration: Int,
val minBufferTime: Float,
val video: List<DashData> = emptyList(),
val audio: List<DashData> = emptyList()
)
@Serializable
data class DashData(
val id: Int,
val baseUrl: String,
val backupUrl: List<String> = emptyList(),
val mimeType: String,
val codecs: String,
val width: Int,
val height: Int,
val frameRate: String,
val sar: String,
val startWithSap: Int,
@SerialName("segment_base")
val segmentBase: SegmentBase,
@SerialName("codecid")
val codecId: Int
)
@Serializable
data class SegmentBase(
val initialization: String,
@SerialName("index_range")
val indexRange: String
)
/**
* 支持的视频格式
*
* @param quality 视频清晰度代码
* @param format 视频格式
* @param newDescription 格式描述
* @param displayDesc 格式描述
* @param superScript (?)
* @param codecs 可用编码格式列表
*/
@Serializable
data class SupportFormat(
val quality: Int,
val format: String,
@SerialName("new_description")
val newDescription: String,
@SerialName("display_desc")
val displayDesc: String,
@SerialName("superscript")
val superScript: String,
val codecs: List<String>? = emptyList()
)
data class DanmakuResponse(
val chatserver: String,
val chatId: Int,
val maxLimit: Int,
val state: Int,
val realName: Int,
val source: String,
val data: List<DanmakuData> = emptyList()
)
data class DanmakuData(
val time: Float,
val type: Int,
val size: Int,
val color: Int,
val timestamp: Int,
val pool: Int,
val midHash: String,
val dmid: Long,
val level: Int,
val text: String
) {
companion object {
fun fromString(p: String, text: String): DanmakuData {
val data = p.split(",")
return DanmakuData(
time = data[0].toFloat(),
type = data[1].toInt(),
size = data[2].toInt(),
color = data[3].toInt(),
timestamp = data[4].toInt(),
pool = data[5].toInt(),
midHash = data[6],
dmid = data[7].toLong(),
level = data[8].toInt(),
text = text
)
}
}
}

View File

@ -44,6 +44,8 @@ import androidx.tv.foundation.lazy.grid.TvLazyVerticalGrid
import androidx.tv.foundation.lazy.grid.items
import androidx.tv.foundation.lazy.list.TvLazyRow
import coil.compose.AsyncImage
import dev.aaa1115910.biliapi.BiliApi
import dev.aaa1115910.biliapi.entity.video.VideoInfo
import dev.aaa1115910.bv.component.HomeCarousel
import dev.aaa1115910.bv.component.TopNav
import dev.aaa1115910.bv.ui.theme.BVTheme

View File

@ -62,6 +62,10 @@ import coil.compose.AsyncImage
import coil.compose.rememberAsyncImagePainter
import coil.request.ImageRequest
import coil.transform.BlurTransformation
import dev.aaa1115910.biliapi.BiliApi
import dev.aaa1115910.biliapi.entity.video.Dimension
import dev.aaa1115910.biliapi.entity.video.VideoInfo
import dev.aaa1115910.biliapi.entity.video.VideoPage
import dev.aaa1115910.bv.component.FavoriteButton
import dev.aaa1115910.bv.component.UpIcon
import dev.aaa1115910.bv.ui.theme.BVTheme
@ -237,7 +241,7 @@ fun VideoInfoData(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row{
Row {
Box(modifier = Modifier.focusable(true)) {}
FavoriteButton(
isFavorite = false,

View File

@ -16,7 +16,7 @@ import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import com.kuaishou.akdanmaku.data.DanmakuItemData
import com.kuaishou.akdanmaku.ui.DanmakuPlayer
import dev.aaa1115910.bv.BiliApi
import dev.aaa1115910.biliapi.BiliApi
import dev.aaa1115910.bv.Keys
import dev.aaa1115910.bv.RequestState
import dev.aaa1115910.bv.util.swapMap

1
bili-api/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

22
bili-api/build.gradle.kts Normal file
View File

@ -0,0 +1,22 @@
plugins {
alias(gradleLibs.plugins.kotlin.jvm)
alias(gradleLibs.plugins.kotlin.serialization)
}
group = "dev.aaa1115910"
dependencies {
implementation(libs.kotlinx.serialization)
implementation(libs.ktor.content.negotiation)
implementation(libs.ktor.core)
implementation(libs.ktor.encoding)
implementation(libs.ktor.okhttp)
implementation(libs.ktor.serialization.kotlinx)
implementation(libs.logging)
implementation(libs.slf4j.android.mvysny)
testImplementation(libs.kotlin.test)
}
tasks.test {
useJUnitPlatform()
}

View File

@ -0,0 +1,169 @@
package dev.aaa1115910.biliapi
import dev.aaa1115910.biliapi.entity.danmaku.DanmakuData
import dev.aaa1115910.biliapi.entity.danmaku.DanmakuResponse
import dev.aaa1115910.biliapi.entity.live.HistoryDanmakuResponse
import dev.aaa1115910.biliapi.entity.live.LiveDanmuInfoResponse
import dev.aaa1115910.biliapi.entity.live.LiveRoomPlayInfoResponse
import dev.aaa1115910.biliapi.entity.video.PlayUrlResponse
import dev.aaa1115910.biliapi.entity.video.PopularVideosResponse
import dev.aaa1115910.biliapi.entity.video.VideoInfoResponse
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.BrowserUserAgent
import io.ktor.client.plugins.compression.ContentEncoding
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.statement.bodyAsChannel
import io.ktor.serialization.kotlinx.json.json
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import mu.KotlinLogging
import javax.xml.parsers.DocumentBuilderFactory
object BiliApi {
private var endPoint: String = ""
private lateinit var client: HttpClient
private val logger = KotlinLogging.logger { }
init {
createClient()
}
private fun createClient() {
client = HttpClient(OkHttp) {
BrowserUserAgent()
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
})
}
install(ContentEncoding) {
deflate(1.0F)
gzip(0.9F)
}
defaultRequest {
host = "api.bilibili.com"
}
}
}
/**
* 获取热门视频列表
*/
suspend fun getPopularVideoData(
pageNumber: Int = 1,
pageSize: Int = 20
): PopularVideosResponse = client.get("/x/web-interface/popular") {
parameter("pn", pageNumber)
parameter("ps", pageSize)
}.body()
/**
* 获取视频详细信息
*/
suspend fun getVideoInfo(
av: Int? = null,
bv: String? = null
): VideoInfoResponse = client.get("/x/web-interface/view") {
parameter("aid", av)
parameter("bvid", bv)
}.body()
/**
* 获取视频流
*/
suspend fun getVideoPlayUrl(
av: Int? = null,
bv: Int? = null,
cid: Int,
qn: Int? = null,
fnval: Int? = null,
fnver: Int? = null,
fourk: Int? = 0,
session: String? = null,
otype: String = "json",
type: String = "",
platform: String = "oc"
): PlayUrlResponse = client.get("/x/player/playurl") {
parameter("avid", av)
parameter("bvid", bv)
parameter("cid", cid)
parameter("qn", qn)
parameter("fnval", fnval)
parameter("fnver", fnver)
parameter("fourk", fourk)
parameter("session", session)
parameter("otype", otype)
parameter("type", type)
parameter("platform", platform)
}.body()
/**
* 通过[cid]获取视频弹幕
*/
suspend fun getDanmakuXml(
cid: Int
): DanmakuResponse {
val xmlChannel = client.get("/x/v1/dm/list.so") {
parameter("oid", cid)
}.bodyAsChannel()
val dbFactory = DocumentBuilderFactory.newInstance()
val dBuilder = dbFactory.newDocumentBuilder()
val doc = withContext(Dispatchers.IO) {
dBuilder.parse(xmlChannel.toInputStream())
}
doc.documentElement.normalize()
val chatServer = doc.getElementsByTagName("chatserver").item(0).textContent
val chatId = doc.getElementsByTagName("chatid").item(0).textContent.toInt()
val maxLimit = doc.getElementsByTagName("maxlimit").item(0).textContent.toInt()
val state = doc.getElementsByTagName("state").item(0).textContent.toInt()
val realName = doc.getElementsByTagName("real_name").item(0).textContent.toInt()
val source = doc.getElementsByTagName("source").item(0).textContent
val data = mutableListOf<DanmakuData>()
val danmakuNodes = doc.getElementsByTagName("d")
for (i in 0 until danmakuNodes.length) {
val danmakuNode = danmakuNodes.item(i)
val p = danmakuNode.attributes.item(0).textContent
val text = danmakuNode.textContent
data.add(DanmakuData.fromString(p, text))
}
val response = DanmakuResponse(chatServer, chatId, maxLimit, state, realName, source, data)
return response
}
/**
* 获取直播间[roomId]的弹幕连接地址等信息,例如 token
*/
suspend fun getLiveDanmuInfo(roomId: Int): LiveDanmuInfoResponse =
client.get("/xlive/web-room/v1/index/getDanmuInfo") {
parameter("id", roomId)
}.body()
/**
* 获取直播间[roomId]的信息
*/
suspend fun getLiveRoomPlayInfo(roomId: Int): LiveRoomPlayInfoResponse =
client.get("/xlive/web-room/v1/index/getRoomPlayInfo") {
parameter("room_id", roomId)
}.body()
/**
* 获取直播间[roomId]的历史弹幕
*/
suspend fun getLiveDanmuHistory(roomId: Int): HistoryDanmakuResponse =
client.get("https://api.live.bilibili.com/xlive/web-room/v1/dM/gethistory") {
parameter("roomid", roomId)
}.body()
}

View File

@ -0,0 +1,306 @@
package dev.aaa1115910.biliapi
import dev.aaa1115910.biliapi.entity.live.DanmakuEvent
import dev.aaa1115910.biliapi.entity.live.FrameHeader
import dev.aaa1115910.biliapi.entity.live.LiveEvent
import dev.aaa1115910.biliapi.entity.live.readFrameHeader
import dev.aaa1115910.biliapi.util.zlibDecompress
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.BrowserUserAgent
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.wss
import io.ktor.utils.io.core.ByteReadPacket
import io.ktor.utils.io.core.buildPacket
import io.ktor.utils.io.core.readBytes
import io.ktor.utils.io.core.toByteArray
import io.ktor.utils.io.core.writeInt
import io.ktor.utils.io.core.writeShort
import io.ktor.websocket.Frame
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import kotlinx.serialization.json.put
import mu.KotlinLogging
object LiveDataWebSocket {
private lateinit var client: HttpClient
private val logger = KotlinLogging.logger { }
private val heartbeat = byteArrayOf(
0, 0, 0, 0x1f,
0, 0x10, 0, 0x1,
0, 0, 0, 0x2,
0, 0, 0, 0x1,
0x5b, 0x6f, 0x62, 0x6a,
0x65, 0x63, 0x74, 0x20,
0x4f, 0x62, 0x6a, 0x65,
0x63, 0x74, 0x5d
)
init {
createClient()
}
private fun createClient() {
client = HttpClient(OkHttp) {
BrowserUserAgent()
install(WebSockets)
}
}
suspend fun connectLiveEvent(
roomId: Int,
onEvent: (event: LiveEvent) -> Unit
) {
val danmuInfo = BiliApi.getLiveDanmuInfo(roomId).data ?: throw CancellationException()
val realRoomId =
BiliApi.getLiveRoomPlayInfo(roomId).data?.roomId ?: throw CancellationException()
val hosts = danmuInfo.hostList.last()
val data = buildJsonObject {
put("uid", 0)
put("roomid", realRoomId)
put("protover", 2)
put("platform", "web")
put("type", 2)
put("key", danmuInfo.token)
}.toString().toByteArray()
val b = buildPacket {
val size = 16 + data.size
writeInt(size) // 封包总大小
writeShort(0x10) // 头部大小
writeShort(1) // 协议版本
writeInt(7) // 类型
writeInt(1)
writePacket(ByteReadPacket(data))
}
val job = client.launch {
client.wss(
host = hosts.host,
port = hosts.wssPort,
path = "/sub"
) {
val byte = b.readBytes()
outgoing.send(Frame.Binary(true, byte))
launch {
delay(5000)
while (isActive) {
//println("send heart")
outgoing.send(Frame.Binary(true, heartbeat))
delay(30_000)
}
}
while (isActive) {
val frame = incoming.receive()
val eventData = frame.data
launch {
handleLiveEventData(eventData).forEach { event ->
onEvent(event)
}
}
}
}
}
job.invokeOnCompletion {
it?.printStackTrace()
}
}
private suspend fun handleLiveEventData(data: ByteArray): List<LiveEvent> {
val result = mutableListOf<LiveEvent>()
withContext(Dispatchers.IO) {
if (data.size <= 16) return@withContext
val bytePack = ByteReadPacket(data)
val head = bytePack.readFrameHeader()
val body = bytePack.readBytes((head.totalLength - head.headerLength))
result.addAll(handleLiveEventBody(head, body))
}
return result
}
private fun handleLiveEventBody(head: FrameHeader, data: ByteArray): List<LiveEvent> {
val result = mutableListOf<LiveEvent>()
val bytePack = ByteReadPacket(data)
when (head.type) {
//心跳包回复(人气值)
3 -> {
//println("接收心跳,房间人气值: ${bytePack.readInt()}")
}
//普通包(命令)
5 -> {
when (head.version.toInt()) {
//0 普通包正文不使用压缩
//1 心跳及认证包正文不使用压缩
0, 1 -> {
val strData = io.ktor.utils.io.core.String(bytePack.readBytes())
handleLiveCMDEventString(strData)?.let { result += it }
}
//普通包正文使用zlib压缩
2 -> {
val decompress = bytePack.readBytes().zlibDecompress()
result += handleLiveEventBodyDecompress(decompress)
}
//普通包正文使用brotli压缩,解压为一个带头部的协议0普通包
3 -> {
logger.warn { "todo package version: ${head.version}" }
bytePack.readBytes()
}
else -> {
logger.warn { "Unknown package version: ${head.version}" }
bytePack.readBytes()
}
}
}
//认证包回复
8 -> {
bytePack.readBytes(10)
}
else -> {
logger.warn { "Unknown package type: ${head.type}" }
bytePack.readBytes()
}
}
return if (bytePack.remaining > 16) result + handleLiveEventBody(
bytePack.readFrameHeader(),
bytePack.readBytes()
)
else result
}
private fun handleLiveEventBodyDecompress(data: ByteArray): List<LiveEvent> {
val result = mutableListOf<LiveEvent>()
val bytePack = ByteReadPacket(data)
val header = bytePack.readFrameHeader()
val body = bytePack.readBytes(header.dataLength)
result += handleLiveCMDEvent(header, body)
return if (bytePack.remaining > 0) result + handleLiveEventBodyDecompress(bytePack.readBytes()) else result
}
private fun handleLiveCMDEvent(head: FrameHeader, data: ByteArray): List<LiveEvent> {
val result = mutableListOf<LiveEvent>()
val strData: String
when (head.version.toInt()) {
0 -> {
strData = io.ktor.utils.io.core.String(data)
}
2 -> {
val decompress = data.zlibDecompress()
val bytePack = ByteReadPacket(decompress)
val packageHeader = bytePack.readFrameHeader()
val body =
bytePack.readBytes((packageHeader.totalLength - packageHeader.headerLength).toInt())
if (bytePack.remaining > 16) {
result += handleLiveEventBody(bytePack.readFrameHeader(), bytePack.readBytes())
}
strData = io.ktor.utils.io.core.String(body)
}
else -> return result
}
handleLiveCMDEventString(strData)?.let { result += it }
return result
}
private fun handleLiveCMDEventString(strData: String): LiveEvent? {
val dataJson = Json.parseToJsonElement(strData).jsonObject
val cmd = dataJson["cmd"]!!.jsonPrimitive.content
when (cmd) {
"COMBO_SEND" -> {}
"DANMU_MSG" -> {
runCatching {
val danmakuContent = dataJson["info"]!!.jsonArray[1].jsonPrimitive.content
val senderMid = dataJson["info"]!!.jsonArray[2].jsonArray[0].jsonPrimitive.long
val senderUsername =
dataJson["info"]!!.jsonArray[2].jsonArray[1].jsonPrimitive.content
var medalLevel: Int? = null
var medalName: String? = null
runCatching {
medalLevel =
dataJson["info"]?.jsonArray?.get(3)?.jsonArray?.get(0)?.jsonPrimitive?.int
medalName =
dataJson["info"]?.jsonArray?.get(3)?.jsonArray?.get(1)?.jsonPrimitive?.content
}
return DanmakuEvent(
content = danmakuContent,
mid = senderMid,
username = senderUsername,
medalName = medalName,
medalLevel = medalLevel
)
}.onFailure {
logger.warn { "Parse danmaku content failed: ${it.message}" }
}
}
"ENTRY_EFFECT" -> {}
//有人上舰
"GUARD_BUY" -> {}
//千舰通知
"GUARD_HONOR_THOUSAND" -> {
println(dataJson)
}
"HOT_RANK_CHANGED" -> {}
"HOT_RANK_CHANGED_V2" -> {}
"HOT_RANK_SETTLEMENT" -> {}
"HOT_RANK_SETTLEMENT_V2" -> {}
"HOT_ROOM_NOTIFY" -> {}
"INTERACT_WORD" -> {}
"LIVE" -> {
println(dataJson)
}
"LIVE_INTERACTIVE_GAME" -> {}
"LIKE_INFO_V3_CLICK" -> {}
"LIKE_INFO_V3_UPDATE" -> {}
"NOTICE_MSG" -> {}
"ONLINE_RANK_COUNT" -> {}
"ONLINE_RANK_V2" -> {}
"ONLINE_RANK_TOP3" -> {}
"PREPARING" -> {}
"ROOM_REAL_TIME_MESSAGE_UPDATE" -> {}
"SEND_GIFT" -> {}
"STOP_LIVE_ROOM_LIST" -> {}
//醒目留言入口提醒(氪金提醒)
"SUPER_CHAT_ENTRANCE" -> {}
//醒目留言
"SUPER_CHAT_MESSAGE" -> {}
//醒目留言
"SUPER_CHAT_MESSAGE_JPN" -> {}
"SYS_MSG" -> {
println(dataJson)
}
"USER_TOAST_MSG" -> {}
"WATCHED_CHANGE" -> {}
"WIDGET_BANNER" -> {}
else -> {
logger.warn { "Unknown live event: $cmd" }
logger.warn { dataJson }
}
}
return null
}
}

View File

@ -0,0 +1,43 @@
package dev.aaa1115910.biliapi.entity.danmaku
data class DanmakuResponse(
val chatserver: String,
val chatId: Int,
val maxLimit: Int,
val state: Int,
val realName: Int,
val source: String,
val data: List<DanmakuData> = emptyList()
)
data class DanmakuData(
val time: Float,
val type: Int,
val size: Int,
val color: Int,
val timestamp: Int,
val pool: Int,
val midHash: String,
val dmid: Long,
val level: Int,
val text: String
) {
companion object {
fun fromString(p: String, text: String): DanmakuData {
val data = p.split(",")
return DanmakuData(
time = data[0].toFloat(),
type = data[1].toInt(),
size = data[2].toInt(),
color = data[3].toInt(),
timestamp = data[4].toInt(),
pool = data[5].toInt(),
midHash = data[6],
dmid = data[7].toLong(),
level = data[8].toInt(),
text = text
)
}
}
}

View File

@ -0,0 +1,166 @@
package dev.aaa1115910.biliapi.entity.live
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
@Serializable
data class HistoryDanmakuResponse(
val code: Int,
val msg: String,
val message: String,
val data: HistoryDanmaku
) {
@Serializable
data class HistoryDanmaku(
//val admin:List<Any>,
val room: List<HistoryDanmakuItem>
) {
@Serializable
data class HistoryDanmakuItem(
val text: String,
@SerialName("dm_type")
val dmType: Int,
val uid: Long,
val nickname: String,
@SerialName("uname_color")
val unameColor: String,
val timeline: String,
@SerialName("isadmin")
val isAdmin: Int,
val vip: Int,
val svip: Int,
@SerialName("medal")
private val _medal: List<JsonElement>,
@Transient
var medal: Medal? = null,
val title: List<String>,
@SerialName("user_level")
val userLevel: List<JsonElement>,
val rank: Int,
@SerialName("teamid")
val teamId: Int,
val rnd: Int,
@SerialName("user_title")
val userTitle: String,
@SerialName("guard_level")
val guardLevel: Int,
val bubble: Int,
@SerialName("bubble_color")
val bubbleColor: String,
val lpl: Int,
@SerialName("yeah_space_url")
val yeahSpaceUrl: String,
@SerialName("jump_to_url")
val jumpToUrl: String,
@SerialName("check_info")
val checkInfo: CheckInfo,
@SerialName("voice_dm_info")
val voiceDmInfo: VoiceDmInfo,
val emoticon: Emoticon
) {
init {
medal = runCatching {
Medal(
level = _medal[0].jsonPrimitive.int,
name = _medal[1].jsonPrimitive.content,
up = _medal[2].jsonPrimitive.content,
roomId = _medal[3].jsonPrimitive.int
)
}.getOrNull()
}
}
}
}
/**
* 粉丝勋章
*
* 返回样例
* ```
* [
* 16, //level
* "迷你鲨", //name
* "hufang360",//up
* 22739471, //up room id
* 12478086,
* "",
* 0,
* 12478086, //medal_color_border可能是
* 12478086, //medal_color_end可能是
* 12478086, //medal_color_start可能是
* 0,
* 1,
* 4328524
* ]
* ```
*
* @param level 勋章等级
* @param name 勋章名称
* @param up 主播昵称
* @param roomId 主播房间号
*/
data class Medal(
val level: Int,
val name: String,
val up: String,
val roomId: Int
)
/*
"medal": [
16, //level
"迷你鲨", //name
"hufang360",//up
22739471, //up room id
12478086,
"",
0,
12478086, //medal_color_border
12478086, //medal_color_end
12478086, //medal_color_start
0,
1,
4328524
],
*/
@Serializable
data class CheckInfo(
val ts: Int,
val ct: String
)
@Serializable
data class VoiceDmInfo(
@SerialName("voice_url")
val voiceUrl: String,
@SerialName("file_format")
val fileFormat: String,
val text: String,
@SerialName("file_duration")
val fileDuration: Int,
@SerialName("file_id")
val fileId: String
)
@Serializable
data class Emoticon(
val id: Int,
@SerialName("emoticon_unique")
val emoticonUnique: String,
val text: String,
val perm: Int,
val url: String,
@SerialName("in_player_area")
val inPlayerArea: Int,
@SerialName("bulge_display")
val bulgeDisplay: Int,
@SerialName("is_dynamic")
val isDynamic: Int,
val height: Int,
val width: Int
)

View File

@ -0,0 +1,38 @@
package dev.aaa1115910.biliapi.entity.live
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LiveDanmuInfoResponse(
val code: Int,
val message: String,
val ttl: Int,
val data: DanmuInfoData? = null
)
@Serializable
data class DanmuInfoData(
val group: String,
@SerialName("business_id")
val businessId: Int,
@SerialName("refresh_row_factor")
val refreshRowFactor: Float,
@SerialName("refresh_rate")
val refreshRate: Int,
@SerialName("max_delay")
val maxDelay: Int,
val token: String,
@SerialName("host_list")
val hostList: List<HostListItem> = emptyList()
)
@Serializable
data class HostListItem(
val host: String,
val port: Int,
@SerialName("wss_port")
val wssPort: Int,
@SerialName("ws_port")
val wsPort: Int
)

View File

@ -0,0 +1,11 @@
package dev.aaa1115910.biliapi.entity.live
interface LiveEvent
data class DanmakuEvent(
val content: String,
val mid: Long,
val username: String,
val medalName: String? = null,
val medalLevel: Int? = null
) : LiveEvent

View File

@ -0,0 +1,106 @@
package dev.aaa1115910.biliapi.entity.live
import io.ktor.utils.io.core.ByteReadPacket
import io.ktor.utils.io.core.buildPacket
import io.ktor.utils.io.core.readInt
import io.ktor.utils.io.core.readShort
import io.ktor.utils.io.core.toByteArray
import io.ktor.utils.io.core.writeInt
import io.ktor.utils.io.core.writeShort
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.io.ByteArrayInputStream
import java.io.DataInputStream
internal fun ByteReadPacket.readFrameHeader(): FrameHeader = FrameHeader(
readInt(), readShort(), readShort(), readInt(), readInt()
)
/**
* 数据包头部
*
* @param totalLength 封包总大小(头部大小+正文大小)
* @param headerLength 头部大小一般为0x001016字节
* @param version 协议版本: 0普通包正文不使用压缩 1心跳及认证包正文不使用压缩2普通包正文使用zlib压缩 3普通包正文使用brotli压缩,解压为一个带头部的协议0普通包
* @param type 操作码(封包类型)
* @param sequence 保留字段不使用
*/
data class FrameHeader(
val totalLength: Int,
val headerLength: Short,
val version: Short,
val type: Int,
val sequence: Int
) {
val dataLength get() = totalLength - headerLength
fun toBinary(): ByteReadPacket {
return buildPacket {
writeInt(this@FrameHeader.totalLength)
writeShort(headerLength)
writeShort(version)
writeInt(this@FrameHeader.type)
writeInt(sequence)
}
}
}
enum class FrameType(val code: Int) {
HeartRequest(2), HeartResponse(3), Normal(5), AuthRequest(7), AuthResponse(8)
}
interface RequestFrame {
fun toBinary(): ByteReadPacket
}
@Serializable
data class AuthRequest(
val uid: Int = 0,
@SerialName("roomid")
val roomId: Int,
@SerialName("protover")
val protoVer: Int = 3,
val platform: String = "web",
val type: Int = 2,
val key: String = ""
) : RequestFrame {
override fun toBinary(): ByteReadPacket {
val data = Json.encodeToString(this).toByteArray()
val header = FrameHeader(
totalLength = data.size + 16,
headerLength = 16,
version = 1,
type = FrameType.AuthRequest.code,
sequence = 1
)
return buildPacket {
this.writePacket(header.toBinary())
writePacket(ByteReadPacket(data))
}
}
}
@Serializable
data class AuthResponse(
val code: Int = -1
) {
companion object {
fun parse(data: ByteArray): AuthResponse {
val bis = ByteArrayInputStream(data)
val dis = DataInputStream(bis)
val totalLength = dis.readInt()
val headerLength = dis.readUnsignedShort()
val version = dis.readUnsignedShort()
val type = dis.readInt()
val sequence = dis.readInt()
//TODO do some verity
val jsonString = String(dis.readBytes())
return Json.decodeFromString(jsonString)
}
}
}

View File

@ -0,0 +1,56 @@
package dev.aaa1115910.biliapi.entity.live
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LiveRoomPlayInfoResponse(
val code: Int,
val message: String,
val ttl: Int,
val data: RoomPlayInfoData? = null
)
@Serializable
data class RoomPlayInfoData(
@SerialName("room_id")
val roomId: Int,
@SerialName("short_id")
val shortId: Int,
val uid: Long,
@SerialName("need_p2p")
val needP2P: Int,
@SerialName("is_hidden")
val isHidden: Boolean,
@SerialName("is_locked")
val isLocked: Boolean,
@SerialName("is_portrait")
val isPortrait: Boolean,
@SerialName("live_status")
val liveStatus: Int,
@SerialName("hidden_till")
val hiddenTill: Int,
@SerialName("lock_till")
val lockTill: Int,
val encrypted: Boolean,
@SerialName("pwd_verified")
val pwdVerified: Boolean,
@SerialName("live_time")
val liveTime: Int,
@SerialName("room_shield")
val roomShield: Int,
@SerialName("is_sp")
val isSp: Int,
@SerialName("special_type")
val specialType: Int,
val playUrl: String? = null,
@SerialName("all_special_types")
val allSpecialTypes: List<Int> = emptyList()
)
/*
"data": {
"play_url": null,
"all_special_types": [
50
]
*/

View File

@ -0,0 +1,70 @@
package dev.aaa1115910.biliapi.entity.subtitle
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* 字幕信息
* @param allowSubmit 是否允许提交字幕
* @param list 字幕列表
*/
@Serializable
data class Subtitle(
val allowSubmit: Boolean = false,
val list: List<SubtitleListItem> = emptyList()
)
/**
* 字幕列表项
*
* @param id 字幕id
* @param lan 字幕语言
* @param lanDoc 字幕语言名称
* @param isLock 是否锁定
* @param authorMid 字幕上传者mid
* @param subtitleUrl json格式字幕文件url
* @param author 字幕上传者信息
*/
@Serializable
data class SubtitleListItem(
val id: Long,
val lan: String,
@SerialName("lan_doc")
val lanDoc: String,
@SerialName("is_lock")
val isLock: Boolean,
@SerialName("author_mid")
val authorMid: Int? = null,
@SerialName("subtitle_url")
val subtitleUrl: String,
val author: SubtitleAuthor
)
/**
* 字幕作者
*
* @param mid 字幕上传者mid
* @param name 字幕上传者昵称
* @param sex 字幕上传者性别 男 女 保密
* @param face 字幕上传者头像url
* @param sign 字幕上传者签名
* @param rank 10000 作用尚不明确
* @param birthday 0 作用尚不明确
* @param isFakeAccount 0 作用尚不明确
* @param isDeleted 0 作用尚不明确
*/
@Serializable
data class SubtitleAuthor(
val mid: Int,
val name: String,
val sex: String,
val face: String,
val sign: String,
val rank: Int,
val birthday: Int,
@SerialName("is_fake_account")
val isFakeAccount: Int,
@SerialName("is_deleted")
val isDeleted: Int
)

View File

@ -0,0 +1,58 @@
package dev.aaa1115910.biliapi.entity.user
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* 创作者个人信息
*
* @param mid 成员mid
* @param title 成员名称
* @param name 成员昵称
* @param face 成员头像url
* @param vip 成员大会员状态
* @param official 成员认证信息
* @param follower 成员粉丝数
*/
@Serializable
data class Staff(
val mid: Int,
val title: String,
val name: String,
val face: String,
val vip: Vip,
val official: Official,
val follower: Int
)
/**
* 会员信息
*
* @param type 成员会员类型 0无 1月会员 2年会员
* @param status 会员状态 0无 1
* @param themeType num 0
*/
@Serializable
data class Vip(
val type: Int,
val status: Int,
@SerialName("theme_type")
val themeType: Int
)
/**
* 认证信息
*
* @param role 成员认证级别 0无 1 2 7个人认证 3 4 5 6机构认证
* @param title 成员认证名 无为空
* @param desc 成员认证备注 无为空
* @param type 成员认证类型 -1无 0
*/
@Serializable
data class Official(
val role: Int,
val title: String,
val desc: String,
val type: Int
)

View File

@ -0,0 +1,15 @@
package dev.aaa1115910.biliapi.entity.user
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* 用户头像挂件
*
* @param urlImageAniCut
*/
@Serializable
data class UserGarb(
@SerialName("url_image_ani_cut")
val urlImageAniCut: String
)

View File

@ -0,0 +1,160 @@
package dev.aaa1115910.biliapi.entity.video
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PlayUrlResponse(
val code: Int,
val message: String,
val ttl: Int,
val data: PlayUrlData? = null
)
/**
* @param from local 作用尚不明确
* @param result suee 作用尚不明确
* @param message 空 作用尚不明确
* @param quality 当前的视频分辨率代码 值含义见上表
* @param format 视频格式
* @param timeLength 视频长度(毫秒值) 单位为毫秒 不同分辨率/格式可能有略微差异
* @param acceptFormat 视频支持的全部格式 每项用,分隔
* @param acceptDescription 视频支持的分辨率列表
* @param acceptQuality 视频支持的分辨率代码列表 值含义见上表
* @param videoCodecId 默认选择视频流的编码id 见视频编码代码
* @param seekParam 固定值start 作用尚不明确
* @param seekType offsetdash、flv secondmp4 作用尚不明确
* @param durl 视频分段 注仅flv/mp4存在此项
* @param dash dash音视频流信息 注仅dash存在此项
* @param supportFormats 支持格式的详细信息
* @param high_format null
* @param lastPlayTime 上次播放进度 毫秒值
* @param lastPlayCid 上次播放分p的cid
*/
@Serializable
data class PlayUrlData(
val from: String,
val result: String,
val message: String,
val quality: Int,
val format: String,
@SerialName("timelength")
val timeLength: Int,
@SerialName("accept_format")
val acceptFormat: String,
@SerialName("accept_description")
val acceptDescription: List<String> = emptyList(),
@SerialName("accept_quality")
val acceptQuality: List<Int> = emptyList(),
@SerialName("video_codecid")
val videoCodecId: Int,
@SerialName("seek_param")
val seekParam: String,
@SerialName("seek_type")
val seekType: String,
val durl: List<Durl> = emptyList(),
val dash: Dash? = null,
@SerialName("support_formats")
val supportFormats: List<SupportFormat> = emptyList(),
@SerialName("last_play_time")
val lastPlayTime: Int,
@SerialName("last_play_cid")
val lastPlayCid: Int
)
/**
* 视频播放地址
*
* @param order 视频分段序号 某些视频会分为多个片段从1顺序增长
* @param length 视频长度 单位为毫秒
* @param size 视频大小 单位为Byte
* @param ahead 空 作用尚不明确
* @param vhead 空 作用尚不明确
* @param url 视频流url 注url内容存在转义符 有效时间为120min
* @param backupUrl 备用视频流
*/
@Serializable
data class Durl(
val order: Int,
val length: Int,
val size: Int,
val ahead: String,
val vhead: String,
val url: String,
@SerialName("backup_url")
val backupUrl: List<String> = emptyList()
)
//TODO
@Serializable
data class Dash(
val duration: Int,
val minBufferTime: Float,
val video: List<DashData> = emptyList(),
val audio: List<DashData> = emptyList()
)
@Serializable
data class DashData(
val id: Int,
val baseUrl: String,
val backupUrl: List<String> = emptyList(),
val mimeType: String,
val codecs: String,
val width: Int,
val height: Int,
val frameRate: String,
val sar: String,
val startWithSap: Int,
@SerialName("segment_base")
val segmentBase: SegmentBase,
@SerialName("codecid")
val codecId: Int
)
@Serializable
data class SegmentBase(
val initialization: String,
@SerialName("index_range")
val indexRange: String
)
/**
* 支持的视频格式
*
* @param quality 视频清晰度代码
* @param format 视频格式
* @param newDescription 格式描述
* @param displayDesc 格式描述
* @param superScript (?)
* @param codecs 可用编码格式列表
*/
@Serializable
data class SupportFormat(
val quality: Int,
val format: String,
@SerialName("new_description")
val newDescription: String,
@SerialName("display_desc")
val displayDesc: String,
@SerialName("superscript")
val superScript: String,
val codecs: List<String>? = emptyList()
)
@Serializable
enum class VideoQuality(val qn: Int, val displayName: String) {
Q260P(6, "240P 极速"),
Q360P(16, "360P 流畅"),
Q480P(32, "480P 清晰"),
Q720P(64, ""),
Q720P60(74, ""),
Q1080P(80, ""),
Q1080PPlus(112, ""),
Q1080P60(116, ""),
Q4K(120, ""),
HDR(125, ""),
Dolby(126, ""),
Q8K(127, "")
}

View File

@ -0,0 +1,19 @@
package dev.aaa1115910.biliapi.entity.video
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PopularVideosResponse(
val code: Int,
val message: String,
val ttl: Int,
val data: PopularVideoData
)
@Serializable
data class PopularVideoData(
val list: List<VideoInfo>,
@SerialName("no_more")
val noMore: Boolean
)

View File

@ -0,0 +1,299 @@
package dev.aaa1115910.biliapi.entity.video
import dev.aaa1115910.biliapi.entity.subtitle.Subtitle
import dev.aaa1115910.biliapi.entity.user.Staff
import dev.aaa1115910.biliapi.entity.user.UserGarb
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* 视频详细信息
*
* @param bvid 稿件bvid
* @param aid 稿件avid
* @param videos 稿件分P总数 默认为1
* @param tid 分区tid
* @param tname 子分区名称
* @param copyright 视频类型 1原创 2转载
* @param pic 稿件封面图片url
* @param title 稿件标题
* @param pubdate 稿件发布时间 秒级时间戳
* @param ctime 用户投稿时间 秒级时间戳
* @param desc 视频简介
* @param state 视频状态
* @param duration 稿件总时长(所有分P) 单位为秒
* @param forward 撞车视频跳转avid 仅撞车视频存在此字段
* @param missionId 稿件参与的活动id
* @param redirectUrl 稿重定向url 仅番剧或影视视频存在此字段,用于番剧&影视的av/bv->ep
* @param rights 视频属性标志
* @param owner 视频UP主信息
* @param stat 视频状态数
* @param dynamic 视频同步发布的的动态的文字内容
* @param cid 视频1P cid
* @param dimension 视频1P分辨率
* @param premiere 首播状态
* @param teenageMode
* @param isChargeableSeason
* @param isStory
* @param noCache
* @param pages 视频分P列表
* @param subtitle 视频CC字幕信息
* @param staff 合作成员列表 非合作视频无此项
* @param isSeasonDisplay
* @param userGarb 用户装扮信息
* @param honorReply
* @param likeIcon
* @param shortLink
* @param shortLinkV2
* @param firstFrame
* @param pubLocation
* @param seasonType
* @param isOgv
* @param ogvInfo
*
*/
@Serializable
data class VideoInfo(
val bvid: String,
val aid: Int,
val videos: Int,
val tid: Int,
val tname: String,
val copyright: Int,
val pic: String,
val title: String,
val pubdate: Int,
val ctime: Int,
val desc: String,
val state: Int,
val duration: Int,
val forward: Int? = null,
@SerialName("mission_id")
val missionId: Int? = null,
@SerialName("redirect_url")
val redirectUrl: String? = null,
val rights: VideoRights,
val owner: VideoOwner,
val stat: VideoStat,
val dynamic: String,
val cid: Int,
val dimension: Dimension,
val premiere: Premiere? = null,
@SerialName("teenage_mode")
val teenageMode: Int = 0,
@SerialName("is_chargeable_season")
val isChargeableSeason: Boolean = false,
@SerialName("is_story")
val isStory: Boolean = false,
@SerialName("no_cache")
val noCache: Boolean = false,
val pages: List<VideoPage> = emptyList(),
val subtitle: Subtitle? = null,
val staff: List<Staff> = emptyList(),
@SerialName("is_season_display")
val isSeasonDisplay: Boolean = false,
@SerialName("user_garb")
val userGarb: UserGarb? = null,
@SerialName("honor_reply")
val honorReply: HonorReply? = null,
@SerialName("like_icon")
val likeIcon: String? = null,
@SerialName("short_link")
val shortLink: String? = null,
@SerialName("short_link_v2")
val shortLinkV2: String? = null,
@SerialName("first_frame")
val firstFrame: String? = null,
@SerialName("pub_location")
val pubLocation: String? = null,
@SerialName("season_type")
val seasonType: Int? = null,
@SerialName("is_ogv")
val isOgv: Boolean = false,
@SerialName("ogv_info")
val ogvInfo: String? = null
)
/**
* 视频属性标志
*
* @param bp 是否允许承包
* @param elec 是否支持充电
* @param download 是否允许下载
* @param movie 是否电影
* @param pay 是否PGC付费
* @param hd5 是否有高码率
* @param noReprint 是否显示“禁止转载”标志
* @param autoplay 是否自动播放
* @param ugcPay 是否UGC付费
* @param isCooperation 是否为联合投稿
* @param ugcPayPreview
* @param noBackground
* @param cleanMode
* @param isSteinGate 是否为互动视频
* @param is360 是否为全景视频
* @param noShare
* @param arcPay
* @param payFreeWatch
*/
@Serializable
data class VideoRights(
val bp: Int,
val elec: Int,
val download: Int,
val movie: Int,
val pay: Int,
val hd5: Int,
@SerialName("no_reprint")
val noReprint: Int,
val autoplay: Int,
@SerialName("ugc_pay")
val ugcPay: Int,
@SerialName("is_cooperation")
val isCooperation: Int,
@SerialName("ugc_pay_preview")
val ugcPayPreview: Int,
@SerialName("no_background")
val noBackground: Int,
@SerialName("clean_mode")
val cleanMode: Int? = null,
@SerialName("is_stein_gate")
val isSteinGate: Int? = null,
@SerialName("is_360")
val is360: Int? = null,
@SerialName("no_share")
val noShare: Int? = null,
@SerialName("arc_pay")
val arcPay: Int,
@SerialName("pay_free_watch")
val payFreeWatch: Int? = null
)
/**
* 视频作者
*
* @param mid UP主mid
* @param name UP主昵称
* @param face UP主头像
*/
@Serializable
data class VideoOwner(
val mid: Long,
val name: String,
val face: String
)
/**
* 视频数据
*
* @param aid 稿件avid
* @param view 播放数
* @param danmaku 弹幕数
* @param reply 评论数
* @param favorite 收藏数
* @param coin 投币数
* @param share 分享数
* @param nowRank 当前排名
* @param hisRank 历史最高排行
* @param like 获赞数
* @param dislike 点踩数 恒为0
* @param evaluation 视频评分
* @param argueMsg 警告/争议提示信息
*/
@Serializable
data class VideoStat(
val aid: Int,
val view: Int,
val danmaku: Int,
val reply: Int,
val favorite: Int,
val coin: Int,
val share: Int,
@SerialName("now_rank")
val nowRank: Int,
@SerialName("his_rank")
val hisRank: Int,
val like: Int,
val dislike: Int,
val evaluation: String = "",
@SerialName("argue_msg")
val argueMsg: String = ""
)
/**
* 分辨率
*
* @param width 当前分P 宽度
* @param height 当前分P 高度
* @param rotate 是否将宽高对换 0正常,1对换
*/
@Serializable
data class Dimension(
val width: Int,
val height: Int,
val rotate: Int
)
@Serializable
data class Premiere(
val state: Int,
@SerialName("start_time")
val startTime: Long,
@SerialName("room_id")
val roomId: Int
)
/**
* 视频分P
*
* @param cid 分P cid
* @param page 分P序号 从1开始
* @param from 视频来源 vupload普通上传B站
* @param hunan芒果TV
* @param qq腾讯
* @param part 分P标题
* @param duration 分P持续时间 单位为秒
* @param vid 站外视频vid 仅站外视频有效
* @param weblink 站外视频跳转url 仅站外视频有效
* @param dimension 当前分P分辨率 部分较老视频无分辨率值
*/
@Serializable
data class VideoPage(
val cid: Int,
val page: Int,
val from: String,
val part: String,
val duration: Int,
val vid: String,
val weblink: String,
val dimension: Dimension
)
/**
* 推荐理由
*
* @param honor
*/
@Serializable
data class HonorReply(
val honor: List<HonorReplyItem> = emptyList()
)
/**
* 推荐信息
*
* @param aid 当前稿件aid
* @param type 2第?期每周必看 3全站排行榜最高第?名 4热门
* @param desc 描述
* @param weeklyRecommendNum
*/
@Serializable
data class HonorReplyItem(
val aid: Int,
val type: Int,
val desc: String,
@SerialName("weekly_recommend_num")
val weeklyRecommendNum: Int
)

View File

@ -0,0 +1,12 @@
package dev.aaa1115910.biliapi.entity.video
import kotlinx.serialization.Serializable
@Serializable
data class VideoInfoResponse(
val code: Int,
val message: String,
val ttl: Int,
val data: VideoInfo
)

View File

@ -0,0 +1,32 @@
package dev.aaa1115910.biliapi.util
import io.ktor.utils.io.core.use
import java.io.ByteArrayOutputStream
import java.util.zip.Deflater
import java.util.zip.Inflater
fun ByteArray.zlibCompress(): ByteArray {
val output = ByteArray(this.size * 4)
val compressor = Deflater().apply {
setInput(this@zlibCompress)
finish()
}
val compressedDataLength: Int = compressor.deflate(output)
return output.copyOfRange(0, compressedDataLength)
}
fun ByteArray.zlibDecompress(): ByteArray {
val inflater = Inflater()
val outputStream = ByteArrayOutputStream()
return outputStream.use {
val buffer = ByteArray(1024)
inflater.setInput(this)
var count = -1
while (count != 0) {
count = inflater.inflate(buffer)
outputStream.write(buffer, 0, count)
}
inflater.end()
outputStream.toByteArray()
}
}

View File

@ -0,0 +1,59 @@
package dev.aaa1115910.biliapi
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
internal class BiliApiTest {
@Test
fun getPopularVideoData() {
assertDoesNotThrow {
runBlocking {
val response = BiliApi.getPopularVideoData()
println(response)
}
}
}
@Test
fun getVideoInfo() {
assertDoesNotThrow {
runBlocking {
val response = BiliApi.getVideoInfo(av = 170001)
println(response)
}
}
}
@Test
fun getVideoPlayUrl() {
assertDoesNotThrow {
runBlocking {
val response = BiliApi.getVideoPlayUrl(av = 170001, cid = 267714)
println(response)
}
}
}
@Test
fun getDanmakuXml() {
assertDoesNotThrow {
runBlocking {
val response = BiliApi.getDanmakuXml(cid = 267714)
println(response)
}
}
}
@Test
fun getLiveDanmuHistory() {
assertDoesNotThrow {
runBlocking {
val response = BiliApi.getLiveDanmuHistory(roomId = 22739471)
println(response)
}
}
}
}

View File

@ -0,0 +1,20 @@
package dev.aaa1115910.biliapi
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
internal class LiveDataWebSocketTest {
@Test
fun connectLiveEvent() {
runBlocking {
LiveDataWebSocket.connectLiveEvent(5555) {
println(it)
}
for (i in 1..10) {
delay(1_000)
}
}
}
}

View File

@ -2,5 +2,6 @@ plugins {
alias(gradleLibs.plugins.android.application) apply false
alias(gradleLibs.plugins.android.library) apply false
alias(gradleLibs.plugins.kotlin.android) apply false
alias(gradleLibs.plugins.kotlin.jvm) apply false
alias(gradleLibs.plugins.kotlin.serialization) apply false
}

View File

@ -6,4 +6,5 @@ kotlin = "1.7.20"
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

View File

@ -23,6 +23,9 @@ koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
# https://kotlinlang.org/docs/jvm-test-using-junit.html
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test" }
# https://github.com/Kotlin/kotlinx.serialization
kotlinx-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }

View File

@ -20,3 +20,4 @@ dependencyResolutionManagement {
}
rootProject.name = "BV"
include(":app")
include(":bili-api")