完成获取视频和番剧播放地址的 API

This commit is contained in:
czp3009 2019-02-24 21:26:49 +08:00
parent 6ed9109b05
commit 47706c10de
18 changed files with 552 additions and 86 deletions

View File

@ -47,6 +47,10 @@ val code = bilibiliApiException.commonResponse.code
登陆和登出均为异步方法, 需要在协程上下文中执行.
如果所使用的语言无法正确调用 `suspend` 方法, 可以使用 `loginFuture` 方法来替代, 它会返回一个 Java8 `CompletableFuture`.
`logoutFuture` 同理.
```kotlin
runBlocking {
BilibiliClient().run {

View File

@ -1,6 +1,7 @@
buildscript {
ext {
kotlin_version = '1.3.21'
kotlin_coroutines_version= '1.1.1'
jvm_target = JavaVersion.VERSION_1_8
}
@ -28,7 +29,9 @@ dependencies {
// https://mvnrepository.com/artifact/org.jetbrains.kotlin/kotlin-stdlib-jdk8
compile group: 'org.jetbrains.kotlin', name: 'kotlin-stdlib-jdk8'
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core
compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: '1.1.1'
compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-core', version: kotlin_coroutines_version
// https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-jdk8
compile group: 'org.jetbrains.kotlinx', name: 'kotlinx-coroutines-jdk8', version: kotlin_coroutines_version
}
compileKotlin {
kotlinOptions {

View File

@ -17,6 +17,8 @@ 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 kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.future.future
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
@ -186,8 +188,7 @@ class BilibiliClient(
.addConverterFactory(gsonConverterFactory)
.addCallAdapterFactory(coroutineCallAdapterFactory)
.client(OkHttpClient.Builder().apply {
//TODO functional
addInterceptor(PlayerInterceptor(billingClientProperties, loginResponse))
addInterceptor(PlayerInterceptor(billingClientProperties) { loginResponse })
addInterceptor(FailureResponseInterceptor)
//log
if (logLevel != HttpLoggingInterceptor.Level.NONE) {
@ -239,6 +240,15 @@ class BilibiliClient(
}
}
/**
* 返回 Future 类型的 login 接口, 用于兼容 Java, 下同
*/
fun loginFuture(username: String, password: String,
challenge: String?,
secCode: String?,
validate: String?
) = GlobalScope.future { login(username, password, challenge, secCode, validate) }
/**
* 登出
* 这个方法不一定是线程安全的, 登出的同时如果进行登陆操作可能引发错误
@ -253,6 +263,11 @@ class BilibiliClient(
loginResponse = null
}
/**
* 返回 Future 类型的 logout 接口
*/
fun logoutFuture() = GlobalScope.future { logout() }
private val sortAndSignInterceptor = SortAndSignInterceptor(billingClientProperties.appSecret)
private inline fun <reified T : Any> createAPI(
baseUrl: String,

View File

@ -47,6 +47,7 @@ class BilibiliClientProperties {
/**
* 屏幕尺寸, 大屏手机(已经没有小屏手机了)统一为 xxhdpi
* 此参数在新版客户端已经较少使用
*/
var scale = "xxhdpi"

View File

@ -0,0 +1,23 @@
package com.hiczp.bilibili.api
import java.security.MessageDigest
//MD5
private val md5Instance = MessageDigest.getInstance("MD5")
internal fun String.md5() =
StringBuilder(32).apply {
//优化过的 md5 字符串生成算法
md5Instance.digest(toByteArray()).forEach {
val value = it.toInt() and 0xFF
val high = value / 16
val low = value - high * 16
append(if (high <= 9) '0' + high else 'a' - 10 + high)
append(if (low <= 9) '0' + low else 'a' - 10 + low)
}
}.toString()
/**
* 签名算法为 "$排序后的参数字符串$appSecret".md5()
*/
fun calculateSign(sortedQuery: String, appSecret: String) = (sortedQuery + appSecret).md5()

View File

@ -1,7 +1,9 @@
package com.hiczp.bilibili.api.main
import com.hiczp.bilibili.api.main.model.ChildReply
import com.hiczp.bilibili.api.main.model.Recommend
import com.hiczp.bilibili.api.main.model.Reply
import com.hiczp.bilibili.api.main.model.Season
import kotlinx.coroutines.Deferred
import retrofit2.http.GET
import retrofit2.http.Query
@ -48,4 +50,28 @@ interface MainAPI {
@Query("sort") sort: Int = 0,
@Query("type") type: Int = 1
): Deferred<ChildReply>
/**
* 获得一个番剧的分季信息, 包含默认季(通常是最新的一季)的分集信息
* seasonId episodeId 必须有一个, 返回的结果是一样的
* 返回值中, 每个 episode 都有 aid cid
*
* @param seasonId 季的唯一标识
* @param episodeId 集的唯一标识
*/
@GET("/pgc/view/app/season")
fun season(
@Query("season_id") seasonId: Long? = null,
@Query("ep_id") episodeId: Long? = null,
@Query("track_path") trackPath: Int? = null
): Deferred<Season>
/**
* 番剧页面下方的推荐(对当前季进行推荐)
* 返回值中的 relates "相关推荐"(广告), season "更多推荐"(其他番, 目标为季)
*
* @param seasonId 季的唯一标识
*/
@GET("/pgc/season/app/related/recommend")
fun recommend(@Query("season_id") seasonId: Long): Deferred<Recommend>
}

View File

@ -1,5 +1,6 @@
package com.hiczp.bilibili.api.main.model
import com.google.gson.JsonElement
import com.google.gson.annotations.SerializedName
data class ChildReply(
@ -156,7 +157,7 @@ data class ChildReply(
@SerializedName("rcount")
var rcount: Int, // 0
@SerializedName("replies")
var replies: List<Any>,
var replies: List<JsonElement>,
@SerializedName("root")
var root: Int, // 1405602348
@SerializedName("root_str")
@ -176,7 +177,7 @@ data class ChildReply(
@SerializedName("device")
var device: String,
@SerializedName("members")
var members: List<Any>,
var members: List<JsonElement>,
@SerializedName("message")
var message: String, // 导演:你认为是否有人了解你?像你自己一样了解你?老佛爷:这个问题我很难回答,别人对我的想法已根深蒂固,所以我认为几乎是不可能,我想是如此,即使是我深爱的人。我不想在别人生活中显得真实,我想成为幽灵,现身,然后消失,我也不想面对任何人的真实,因为我不想面对真实的自己,那是我的秘密。别跟我说那些关于孤独的陈词滥调,之于我这种人,孤独是一种胜利,这是场人生战役。像我一样从事创意工作的人,必须独处,让自己重新充电,整日生活在聚光灯前是无法创作的。我还要做许多事,例如阅读,身边有人就无法去做。平时几乎已没时间,但我随时都会想阅读,所以我赞成每人都要该独立生活。将别人当成依靠,对于我这样的人来说很危险,我必须时时刻刻如履薄冰,并在它破裂之前跨出下一步。
@SerializedName("plat")
@ -196,7 +197,7 @@ data class ChildReply(
@SerializedName("avatar")
var avatar: String, // http://i2.hdslb.com/bfs/face/63f5da7bda813e470cefd465767035efccff747d.jpg
@SerializedName("fans_detail")
var fansDetail: Any?, // null
var fansDetail: JsonElement?, // null
@SerializedName("following")
var following: Int, // 0
@SerializedName("level_info")
@ -294,7 +295,7 @@ data class ChildReply(
@SerializedName("device")
var device: String,
@SerializedName("members")
var members: List<Any>,
var members: List<JsonElement>,
@SerializedName("message")
var message: String, // 唉有点不敢相信…R.I.P……走好走好
@SerializedName("plat")
@ -314,7 +315,7 @@ data class ChildReply(
@SerializedName("avatar")
var avatar: String, // http://i2.hdslb.com/bfs/face/63f5da7bda813e470cefd465767035efccff747d.jpg
@SerializedName("fans_detail")
var fansDetail: Any?, // null
var fansDetail: JsonElement?, // null
@SerializedName("following")
var following: Int, // 0
@SerializedName("level_info")

View File

@ -0,0 +1,89 @@
package com.hiczp.bilibili.api.main.model
import com.google.gson.JsonElement
import com.google.gson.annotations.SerializedName
data class Recommend(
@SerializedName("code")
var code: Int, // 0
@SerializedName("message")
var message: String, // success
@SerializedName("result")
var result: Result
) {
data class Result(
@SerializedName("card")
var card: JsonElement, // []
@SerializedName("relates")
var relates: List<Relate>,
@SerializedName("season")
var season: List<Season>
) {
data class Season(
@SerializedName("badge")
var badge: String, // 会员抢先
@SerializedName("badge_type")
var badgeType: Int, // 0
@SerializedName("cover")
var cover: String, // http://i0.hdslb.com/bfs/bangumi/3fc16a667502cbff226e585eb660a96a20c7458c.png
@SerializedName("from")
var from: Int, // 0
@SerializedName("new_ep")
var newEp: NewEp,
@SerializedName("rating")
var rating: Rating,
@SerializedName("season_id")
var seasonId: Int, // 26146
@SerializedName("season_type")
var seasonType: Int, // 1
@SerializedName("stat")
var stat: Stat,
@SerializedName("title")
var title: String, // 多罗罗
@SerializedName("url")
var url: String // http://www.bilibili.com/bangumi/play/ss26146
) {
data class NewEp(
@SerializedName("cover")
var cover: String, // http://i0.hdslb.com/bfs/archive/7dec9d820b82ee57ebde0ba5c186b63e1e728abd.jpg
@SerializedName("index_show")
var indexShow: String // 更新至第7话
)
data class Rating(
@SerializedName("count")
var count: Long, // 22916
@SerializedName("score")
var score: Double // 9.8
)
data class Stat(
@SerializedName("danmaku")
var danmaku: Int, // 435439
@SerializedName("follow")
var follow: Int, // 2073877
@SerializedName("view")
var view: Int // 24884016
)
}
data class Relate(
@SerializedName("desc1")
var desc1: String, // 【萌羽Moeyu】魔法禁书目录御坂美琴易拉罐保温杯
@SerializedName("desc2")
var desc2: String, // 295
@SerializedName("item_id")
var itemId: Int, // 10005816
@SerializedName("pic")
var pic: String, // https://i0.hdslb.com/bfs/mall/mall/c3/f0/c3f029d8221c6ecc96bd1ab321034bc2.jpg
@SerializedName("title")
var title: String, // 【现货即发】魔法禁书目录正版授权Moeyu出品。
@SerializedName("type")
var type: Int, // 1
@SerializedName("type_name")
var typeName: String, // 商品
@SerializedName("url")
var url: String // bilibili://mall/web?url=https%3A%2F%2Fmall.bilibili.com%2Fdetail.html%3FitemsId%3D10005816%26msource%3Dfanju_25617_10005816%26noTitleBar%3D1%26loadingShow%3D1
)
}
}

View File

@ -0,0 +1,283 @@
package com.hiczp.bilibili.api.main.model
import com.google.gson.JsonElement
import com.google.gson.annotations.SerializedName
data class Season(
@SerializedName("code")
var code: Int, // 0
@SerializedName("message")
var message: String, // success
@SerializedName("result")
var result: Result
) {
data class Result(
@SerializedName("cover")
var cover: String, // http://i0.hdslb.com/bfs/bangumi/a92892921f3209f7784a954c37467c9869a1d4c1.png
@SerializedName("episodes")
var episodes: List<Episode>,
@SerializedName("evaluate")
var evaluate: String, // 位于东京西部的巨大“学园都市”实施着超能力开发的特殊课程。学生们的能力被给予从“无能力Level 0”到“超能力Level 5”的六阶段评价。高中生上条当麻由于寄宿在右手中的力量——只要是异能之力...
@SerializedName("link")
var link: String, // http://www.bilibili.com/bangumi/media/md134912/
@SerializedName("media_id")
var mediaId: Int, // 134912
@SerializedName("mode")
var mode: Int, // 2
@SerializedName("new_ep")
var newEp: NewEp,
@SerializedName("paster")
var paster: Paster,
@SerializedName("payment")
var payment: Payment,
@SerializedName("publish")
var publish: Publish,
@SerializedName("rating")
var rating: Rating,
@SerializedName("record")
var record: String,
@SerializedName("rights")
var rights: Rights,
@SerializedName("season_id")
var seasonId: Long, // 25617
@SerializedName("season_title")
var seasonTitle: String, // 魔法禁书目录 第三季
@SerializedName("seasons")
var seasons: List<Season>,
@SerializedName("section")
var section: List<JsonElement>,
@SerializedName("series")
var series: Series,
@SerializedName("share_url")
var shareUrl: String, // http://m.bilibili.com/bangumi/play/ss25617
@SerializedName("square_cover")
var squareCover: String, // http://i0.hdslb.com/bfs/bangumi/91b29251445f9b808e9c30f34019d3ba4f128d6d.jpg
@SerializedName("stat")
var stat: Stat,
@SerializedName("status")
var status: Int, // 13
@SerializedName("title")
var title: String, // 魔法禁书目录 第三季
@SerializedName("total")
var total: Int, // 0
@SerializedName("type")
var type: Int, // 1
@SerializedName("user_status")
var userStatus: UserStatus
) {
data class Series(
@SerializedName("series_id")
var seriesId: Int, // 621
@SerializedName("series_title")
var seriesTitle: String // 魔法禁书目录
)
data class UserStatus(
@SerializedName("follow")
var follow: Int, // 1
@SerializedName("pay")
var pay: Int, // 0
@SerializedName("progress")
var progress: Progress,
@SerializedName("review")
var review: Review,
@SerializedName("sponsor")
var sponsor: Int, // 0
@SerializedName("vip")
var vip: Int, // 0
@SerializedName("vip_frozen")
var vipFrozen: Int // 0
) {
data class Progress(
@SerializedName("last_ep_id")
var lastEpId: Int, // 250436
@SerializedName("last_ep_index")
var lastEpIndex: String, // 3
@SerializedName("last_time")
var lastTime: Int // 1405
)
data class Review(
@SerializedName("is_open")
var isOpen: Int // 0
)
}
data class Season(
@SerializedName("is_new")
var isNew: Int, // 1
@SerializedName("season_id")
var seasonId: Long, // 25617
@SerializedName("season_title")
var seasonTitle: String // 第三季
)
data class Episode(
@SerializedName("aid")
var aid: Int, // 44389470
@SerializedName("badge")
var badge: String, // 会员
@SerializedName("badge_type")
var badgeType: Int, // 0
@SerializedName("cid")
var cid: Int, // 77725026
@SerializedName("cover")
var cover: String, // http://i0.hdslb.com/bfs/archive/c83ef2a961d8b53d6a30f03e8fb631ea4248fede.jpg
@SerializedName("dimension")
var dimension: Dimension,
@SerializedName("from")
var from: String, // bangumi
@SerializedName("id")
var id: Int, // 250453
@SerializedName("long_title")
var longTitle: String, // 守护的理由
@SerializedName("share_url")
var shareUrl: String, // https://m.bilibili.com/bangumi/play/ep250453
@SerializedName("status")
var status: Int, // 13
@SerializedName("title")
var title: String, // 20
@SerializedName("vid")
var vid: String
) {
data class Dimension(
@SerializedName("height")
var height: Int, // 1080
@SerializedName("rotate")
var rotate: Int, // 0
@SerializedName("width")
var width: Int // 1920
)
}
data class Rating(
@SerializedName("count")
var count: Int, // 32318
@SerializedName("score")
var score: Double // 7.8
)
data class Rights(
@SerializedName("allow_bp")
var allowBp: Int, // 0
@SerializedName("allow_download")
var allowDownload: Int, // 0
@SerializedName("allow_review")
var allowReview: Int, // 1
@SerializedName("area_limit")
var areaLimit: Int, // 0
@SerializedName("ban_area_show")
var banAreaShow: Int, // 1
@SerializedName("copyright")
var copyright: String, // bilibili
@SerializedName("is_preview")
var isPreview: Int, // 1
@SerializedName("watch_platform")
var watchPlatform: Int // 0
)
data class NewEp(
@SerializedName("desc")
var desc: String, // 连载中, 每周五22:30更新
@SerializedName("id")
var id: Int, // 250453
@SerializedName("is_new")
var isNew: Int, // 1
@SerializedName("title")
var title: String // 20
)
data class Payment(
@SerializedName("dialog")
var dialog: Dialog,
@SerializedName("pay_tip")
var payTip: PayTip,
@SerializedName("pay_type")
var payType: PayType,
@SerializedName("price")
var price: String, // 0.0
@SerializedName("vip_promotion")
var vipPromotion: String
) {
data class Dialog(
@SerializedName("btn_right")
var btnRight: BtnRight,
@SerializedName("desc")
var desc: String,
@SerializedName("title")
var title: String // 开通大会员抢先看
) {
data class BtnRight(
@SerializedName("title")
var title: String, // 成为大会员
@SerializedName("type")
var type: String // vip
)
}
data class PayType(
@SerializedName("allow_ticket")
var allowTicket: Int // 0
)
data class PayTip(
@SerializedName("primary")
var primary: Primary
) {
data class Primary(
@SerializedName("sub_title")
var subTitle: String,
@SerializedName("title")
var title: String, // 开通大会员抢先看
@SerializedName("type")
var type: Int, // 1
@SerializedName("url")
var url: String
)
}
}
data class Stat(
@SerializedName("coins")
var coins: Long, // 333230
@SerializedName("danmakus")
var danmakus: Int, // 729836
@SerializedName("favorites")
var favorites: Int, // 2300833
@SerializedName("reply")
var reply: Int, // 324057
@SerializedName("share")
var share: Int, // 18362
@SerializedName("views")
var views: Int // 41392306
)
data class Publish(
@SerializedName("is_finish")
var isFinish: Int, // 0
@SerializedName("is_started")
var isStarted: Int, // 1
@SerializedName("pub_time")
var pubTime: String, // 2018-10-05 22:30:00
@SerializedName("pub_time_show")
var pubTimeShow: String, // 10月05日22:30
@SerializedName("weekday")
var weekday: Int // 0
)
data class Paster(
@SerializedName("aid")
var aid: Long, // 0
@SerializedName("allow_jump")
var allowJump: Int, // 0
@SerializedName("cid")
var cid: Long, // 0
@SerializedName("duration")
var duration: Int, // 0
@SerializedName("type")
var type: Int, // 0
@SerializedName("url")
var url: String
)
}
}

View File

@ -1,11 +1,12 @@
package com.hiczp.bilibili.api.player
import com.hiczp.bilibili.api.md5
import com.hiczp.bilibili.api.player.model.BangumiPlayUrl
import com.hiczp.bilibili.api.player.model.VideoPlayUrl
import kotlinx.coroutines.Deferred
import retrofit2.http.GET
import retrofit2.http.Query
import java.util.*
import java.lang.management.ManagementFactory
/**
* 这里是播放器会访问的 API
@ -16,7 +17,6 @@ interface PlayerAPI {
* 获得视频的播放地址
* 这个 API 需要使用特别的 appKey
*
* @param expire 默认为下个月的这一天的时间戳
* @param cid 在获取视频详情页面的接口的返回值里
* @param aid 视频的唯一标识
*
@ -24,13 +24,11 @@ interface PlayerAPI {
*/
@GET(videoPlayUrl)
fun videoPlayUrl(
@Query("expire") expire: Long = nextMonthTimestamp(),
@Query("force_host") forceHost: Int = 0,
@Query("fnval") fnVal: Int = 16,
@Query("qn") qn: Int = 32,
@Query("npcybs") npcybs: Int = 0,
@Query("cid") cid: Long,
@Query("otype") otype: String = "json",
@Query("fnver") fnVer: Int = 0,
@Query("aid") aid: Long
): Deferred<VideoPlayUrl>
@ -40,28 +38,27 @@ interface PlayerAPI {
*
* @param aid 番剧的唯一标识
* @param cid 在番剧详情页的返回值里
* @param seasonType 番剧分季(第一季, 第二季)( 1 开始)
* @param session 不明确其含义
* @param seasonType 分级类型, 不明确, 似乎总为 1
* @param session 其值为 系统已运行时间(ms)的MD5值, 此处的默认值为 JVM 已启动时间, Android 上请使用 SystemClock
* @param trackPath 不明确
*
* @see com.hiczp.bilibili.api.main.MainAPI.season
*/
@GET("https://api.bilibili.com/pgc/player/api/playurl")
fun bangumiPlayUrl(
@Query("aid") aid: Long,
@Query("cid") cid: Long,
@Query("expire") expire: Long = nextMonthTimestamp(),
@Query("fnval") fnVal: Int = 16,
@Query("fnver") fnVer: Int = 0,
@Query("module") module: String = "bangumi",
@Query("npcybs") npcybs: Int = 0,
@Query("otype") otype: String = "json",
@Query("qn") qn: Int = 32,
@Query("season_type") seasonType: Int,
@Query("session") session: String? = null,
@Query("season_type") seasonType: Int = 1,
@Query("session") session: String = (System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().startTime).toString().md5(),
@Query("track_path") trackPath: Int? = null
): Deferred<BangumiPlayUrl>
companion object {
const val videoPlayUrl = "https://app.bilibili.com/x/playurl"
private fun nextMonthTimestamp() = Calendar.getInstance().apply { add(Calendar.MONTH, 1) }.toInstant().epochSecond
}
}

View File

@ -1,11 +1,14 @@
package com.hiczp.bilibili.api.player
import com.hiczp.bilibili.api.BilibiliClientProperties
import com.hiczp.bilibili.api.calculateSign
import com.hiczp.bilibili.api.passport.model.LoginResponse
import com.hiczp.bilibili.api.retrofit.Charsets.UTF_8
import com.hiczp.bilibili.api.retrofit.Header
import com.hiczp.bilibili.api.retrofit.Param
import okhttp3.Interceptor
import okhttp3.Response
import java.net.URLEncoder
import java.time.Instant
/**
@ -15,37 +18,54 @@ import java.time.Instant
*/
class PlayerInterceptor(
private val bilibiliClientProperties: BilibiliClientProperties,
private val loginResponse: LoginResponse?
private val loginResponseExpression: () -> LoginResponse?
) : Interceptor {
@Suppress("SpellCheckingInspection")
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
//添加 header
val header = request.headers().newBuilder().apply {
add("Accept", "*/*")
add("User-Agent", "Bilibili Freedoooooom/MarkII")
add("Accept-Language", Header.ZH_CN)
}.build()
val url = request.url().newBuilder().apply {
//视频播放地址(非番剧)这个接口要用 videoAppKey
if (request.url().toString().startsWith(PlayerAPI.videoPlayUrl)) {
addQueryParameter(Param.APP_KEY, bilibiliClientProperties.videoAppKey)
//添加 Query Params
val oldUrl = request.url()
//如果是视频播放地址这个 API, 要用特殊的 appKey
val isVideo = oldUrl.toString().startsWith(PlayerAPI.videoPlayUrl)
val url = StringBuilder(oldUrl.encodedQuery() ?: "").apply {
//appKey
addParamEncode(Param.APP_KEY, if (isVideo) bilibiliClientProperties.videoAppKey else bilibiliClientProperties.appKey)
//凭证有关
val loginRespons = loginResponseExpression()
if (loginRespons != null) {
//expire 的值为 token过期时间+2s
addParamEncode(Param.EXPIRE, (loginRespons.ts + loginRespons.data.tokenInfo.expiresIn + 2).toString())
addParamEncode(Param.ACCESS_KEY, loginRespons.token)
addParamEncode(Param.MID, loginRespons.userId.toString())
} else {
addQueryParameter(Param.APP_KEY, bilibiliClientProperties.appKey)
addParamEncode(Param.EXPIRE, "0")
addParamEncode(Param.MID, "0")
}
//公共参数
addQueryParameter("device", bilibiliClientProperties.platform)
addQueryParameter("mobi_app", bilibiliClientProperties.platform)
if (loginResponse != null) {
addQueryParameter("mid", loginResponse.userId.toString())
addQueryParameter(Param.ACCESS_KEY, loginResponse.token)
}
addQueryParameter("platform", bilibiliClientProperties.platform)
addQueryParameter("ts", Instant.now().epochSecond.toString())
addQueryParameter("build", bilibiliClientProperties.build)
addQueryParameter("buvid", bilibiliClientProperties.buildVersionId)
}.build()
addParamEncode("device", bilibiliClientProperties.platform)
addParamEncode("mobi_app", bilibiliClientProperties.platform)
addParamEncode("platform", bilibiliClientProperties.platform)
addParamEncode("otype", "json")
addParamEncode("ts", Instant.now().epochSecond.toString())
addParamEncode("build", bilibiliClientProperties.build)
addParamEncode("buvid", bilibiliClientProperties.buildVersionId)
}.toString().let {
//排序
val sortedEncodedQuery = it.split('&').sorted().joinToString(separator = "&")
//添加 sign
val sign = calculateSign(sortedEncodedQuery, if (isVideo) bilibiliClientProperties.videoAppSecret else bilibiliClientProperties.appSecret)
"$sortedEncodedQuery&${Param.SIGN}=$sign"
}.let {
oldUrl.newBuilder().encodedQuery(it).build()
}
return chain.proceed(
request.newBuilder()
@ -55,3 +75,10 @@ class PlayerInterceptor(
)
}
}
private fun StringBuilder.addParamEncode(name: String, value: String) {
if (length != 0) append('&')
append(name)
append('=')
append(URLEncoder.encode(value, UTF_8))
}

View File

@ -21,5 +21,11 @@ object Param {
const val ACCESS_KEY = "access_key"
@Suppress("SpellCheckingInspection")
const val APP_KEY = "appkey"
const val EXPIRE = "expire"
const val MID = "mid"
const val SIGN = "sign"
}
internal object Charsets {
const val UTF_8 = "UTF-8"
}

View File

@ -1,5 +1,6 @@
package com.hiczp.bilibili.api.retrofit.interceptor
import com.hiczp.bilibili.api.calculateSign
import com.hiczp.bilibili.api.retrofit.Param
import com.hiczp.bilibili.api.retrofit.containsEncodedName
import com.hiczp.bilibili.api.retrofit.sortedRaw
@ -7,7 +8,6 @@ import mu.KotlinLogging
import okhttp3.FormBody
import okhttp3.Interceptor
import okhttp3.Response
import java.security.MessageDigest
private val logger = KotlinLogging.logger {}
@ -27,14 +27,10 @@ class SortAndSignInterceptor(private val appSecret: String) : Interceptor {
request = when {
//判断 appKey 是否在 Query 里
url.queryParameter(Param.APP_KEY) != null -> {
val sortedEncodedQuery = url.encodedQuery()!!
.split('&')
.sorted()
.joinToString(separator = "&")
val sign = calculateSign(sortedEncodedQuery, appSecret)
val sortedEncodedQuery = url.encodedQuery()!!.split('&').sorted().joinToString(separator = "&")
request.newBuilder()
.url(url.newBuilder()
.encodedQuery("$sortedEncodedQuery&${Param.SIGN}=$sign")
.encodedQuery("$sortedEncodedQuery&${Param.SIGN}=${calculateSign(sortedEncodedQuery, appSecret)}")
.build()
).build()
}
@ -42,13 +38,12 @@ class SortAndSignInterceptor(private val appSecret: String) : Interceptor {
//在 FormBody 里
body is FormBody && body.containsEncodedName(Param.APP_KEY) -> {
val sortedRaw = body.sortedRaw()
val sign = calculateSign(sortedRaw, appSecret)
val formBody = FormBody.Builder().apply {
sortedRaw.split('&').forEach {
val (name, value) = it.split('=')
addEncoded(name, value)
}
addEncoded(Param.SIGN, sign)
addEncoded(Param.SIGN, calculateSign(sortedRaw, appSecret))
}.build()
request.newBuilder()
.method(request.method(), formBody)
@ -64,22 +59,4 @@ class SortAndSignInterceptor(private val appSecret: String) : Interceptor {
return chain.proceed(request)
}
companion object {
private val md5Instance = MessageDigest.getInstance("MD5")
/**
* 签名算法为 "$排序后的参数字符串$appSecret".md5()
*/
private fun calculateSign(string: String, appSecret: String) =
StringBuilder(32).apply {
//优化过的 md5 字符串生成算法
md5Instance.digest((string + appSecret).toByteArray()).forEach {
val value = it.toInt() and 0xFF
val high = value / 16
val low = value - high * 16
append(if (high <= 9) '0' + high else 'a' - 10 + high)
append(if (low <= 9) '0' + low else 'a' - 10 + low)
}
}.toString()
}
}

View File

@ -7,25 +7,26 @@ import com.google.gson.JsonObject
import com.hiczp.bilibili.api.BilibiliClient
import okhttp3.logging.HttpLoggingInterceptor
//配置文件
@Suppress("SpellCheckingInspection")
private val gson = Gson()
private val config = gson.fromJson<JsonObject>(
Config::class.java.getResourceAsStream("/config.json").reader()
)
//未登录的实例
val noLoginBilibiliClient = BilibiliClient(logLevel = HttpLoggingInterceptor.Level.BODY)
//登陆过的实例
val bilibiliClient by lazy {
BilibiliClient(logLevel = HttpLoggingInterceptor.Level.BODY).apply {
loginResponse = config["loginResponse"]?.let { gson.fromJson(it) }
}
}
object Config {
@Suppress("SpellCheckingInspection")
private val gson = Gson()
private val config = gson.fromJson<JsonObject>(
Config::class.java.getResourceAsStream("/config.json").reader()
)
val username by config.byString
val password by config.byString
//登陆过的实例
val bilibiliClient by lazy {
BilibiliClient(logLevel = HttpLoggingInterceptor.Level.BODY).apply {
loginResponse = config["loginResponse"]?.let { gson.fromJson(it) }
}
}
//未登录的实例
val noLoginBilibiliClient = BilibiliClient(logLevel = HttpLoggingInterceptor.Level.BODY)
}

View File

@ -7,14 +7,14 @@ class FetchReplyTest {
@Test
fun fetchReply() {
runBlocking {
Config.noLoginBilibiliClient.mainAPI.reply(oid = 44154463).await()
noLoginBilibiliClient.mainAPI.reply(oid = 44154463).await()
}
}
@Test
fun fetchChildReply() {
runBlocking {
Config.noLoginBilibiliClient.mainAPI.childReply(oid = 16622855, root = 1405602348).await()
noLoginBilibiliClient.mainAPI.childReply(oid = 16622855, root = 1405602348).await()
}
}
}

View File

@ -7,7 +7,7 @@ class PlayUrlTest {
@Test
fun videoPlayUrl() {
runBlocking {
Config.noLoginBilibiliClient.playerAPI.run {
bilibiliClient.playerAPI.run {
videoPlayUrl(aid = 41517911, cid = 72913641).await()
}
}
@ -16,8 +16,8 @@ class PlayUrlTest {
@Test
fun bangumiPlayUrl() {
runBlocking {
Config.noLoginBilibiliClient.playerAPI.run {
bangumiPlayUrl(aid = 42714241, cid = 74921228, seasonType = 1).await()
bilibiliClient.playerAPI.run {
bangumiPlayUrl(aid = 42714241, cid = 74921228).await()
}
}
}

View File

@ -0,0 +1,13 @@
package com.hiczp.bilibili.api.test
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
class SeasonTest {
@Test
fun season() {
runBlocking {
noLoginBilibiliClient.mainAPI.season(episodeId = 250536).await()
}
}
}

View File

@ -7,7 +7,7 @@ class UserInfoTest {
@Test
fun info() {
runBlocking {
Config.bilibiliClient.appAPI.myInfo().await()
bilibiliClient.appAPI.myInfo().await()
}
}
}