diff --git a/README.md b/README.md index 35469b0..c37aa4d 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,10 @@ val code = bilibiliApiException.commonResponse.code 登陆和登出均为异步方法, 需要在协程上下文中执行. +如果所使用的语言无法正确调用 `suspend` 方法, 可以使用 `loginFuture` 方法来替代, 它会返回一个 Java8 `CompletableFuture`. + +`logoutFuture` 同理. + ```kotlin runBlocking { BilibiliClient().run { diff --git a/build.gradle b/build.gradle index 7649b86..eaed97a 100644 --- a/build.gradle +++ b/build.gradle @@ -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 { diff --git a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt index fb599c3..6b6310c 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClient.kt @@ -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 createAPI( baseUrl: String, diff --git a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt index 2b49808..39e9a10 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/BilibiliClientProperties.kt @@ -47,6 +47,7 @@ class BilibiliClientProperties { /** * 屏幕尺寸, 大屏手机(已经没有小屏手机了)统一为 xxhdpi + * 此参数在新版客户端已经较少使用 */ var scale = "xxhdpi" diff --git a/src/main/kotlin/com/hiczp/bilibili/api/CipherExtension.kt b/src/main/kotlin/com/hiczp/bilibili/api/CipherExtension.kt new file mode 100644 index 0000000..36384d3 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/CipherExtension.kt @@ -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() diff --git a/src/main/kotlin/com/hiczp/bilibili/api/main/MainAPI.kt b/src/main/kotlin/com/hiczp/bilibili/api/main/MainAPI.kt index 5ad6207..aa0fad5 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/main/MainAPI.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/main/MainAPI.kt @@ -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 + + /** + * 获得一个番剧的分季信息, 包含默认季(通常是最新的一季)的分集信息 + * 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 + + /** + * 番剧页面下方的推荐(对当前季进行推荐) + * 返回值中的 relates 是 "相关推荐"(广告), season 是 "更多推荐"(其他番, 目标为季) + * + * @param seasonId 季的唯一标识 + */ + @GET("/pgc/season/app/related/recommend") + fun recommend(@Query("season_id") seasonId: Long): Deferred } diff --git a/src/main/kotlin/com/hiczp/bilibili/api/main/model/ChildReply.kt b/src/main/kotlin/com/hiczp/bilibili/api/main/model/ChildReply.kt index 136864e..2a5b11f 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/main/model/ChildReply.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/main/model/ChildReply.kt @@ -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, + var replies: List, @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, + var members: List, @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, + var members: List, @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") diff --git a/src/main/kotlin/com/hiczp/bilibili/api/main/model/Recommend.kt b/src/main/kotlin/com/hiczp/bilibili/api/main/model/Recommend.kt new file mode 100644 index 0000000..df67ba9 --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/main/model/Recommend.kt @@ -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, + @SerializedName("season") + var season: List + ) { + 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 + ) + } +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/main/model/Season.kt b/src/main/kotlin/com/hiczp/bilibili/api/main/model/Season.kt new file mode 100644 index 0000000..176253d --- /dev/null +++ b/src/main/kotlin/com/hiczp/bilibili/api/main/model/Season.kt @@ -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, + @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, + @SerializedName("section") + var section: List, + @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 + ) + } +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/player/PlayerAPI.kt b/src/main/kotlin/com/hiczp/bilibili/api/player/PlayerAPI.kt index d1cfac1..b30b2e2 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/player/PlayerAPI.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/player/PlayerAPI.kt @@ -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 @@ -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 companion object { const val videoPlayUrl = "https://app.bilibili.com/x/playurl" - private fun nextMonthTimestamp() = Calendar.getInstance().apply { add(Calendar.MONTH, 1) }.toInstant().epochSecond } } diff --git a/src/main/kotlin/com/hiczp/bilibili/api/player/PlayerInterceptor.kt b/src/main/kotlin/com/hiczp/bilibili/api/player/PlayerInterceptor.kt index ace82a3..d2d3125 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/player/PlayerInterceptor.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/player/PlayerInterceptor.kt @@ -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)) +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/HttpConstant.kt b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/HttpConstant.kt index 4160548..dd8ac83 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/HttpConstant.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/HttpConstant.kt @@ -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" +} diff --git a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/SortAndSignInterceptor.kt b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/SortAndSignInterceptor.kt index 10cf6c8..933b2b5 100644 --- a/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/SortAndSignInterceptor.kt +++ b/src/main/kotlin/com/hiczp/bilibili/api/retrofit/interceptor/SortAndSignInterceptor.kt @@ -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() - } } diff --git a/src/test/kotlin/com/hiczp/bilibili/api/test/Config.kt b/src/test/kotlin/com/hiczp/bilibili/api/test/Config.kt index ef83e82..009523b 100644 --- a/src/test/kotlin/com/hiczp/bilibili/api/test/Config.kt +++ b/src/test/kotlin/com/hiczp/bilibili/api/test/Config.kt @@ -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( + 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( - 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) } diff --git a/src/test/kotlin/com/hiczp/bilibili/api/test/FetchReplyTest.kt b/src/test/kotlin/com/hiczp/bilibili/api/test/FetchReplyTest.kt index e61d4b5..1c21e58 100644 --- a/src/test/kotlin/com/hiczp/bilibili/api/test/FetchReplyTest.kt +++ b/src/test/kotlin/com/hiczp/bilibili/api/test/FetchReplyTest.kt @@ -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() } } } diff --git a/src/test/kotlin/com/hiczp/bilibili/api/test/PlayUrlTest.kt b/src/test/kotlin/com/hiczp/bilibili/api/test/PlayUrlTest.kt index 94331ed..86628b8 100644 --- a/src/test/kotlin/com/hiczp/bilibili/api/test/PlayUrlTest.kt +++ b/src/test/kotlin/com/hiczp/bilibili/api/test/PlayUrlTest.kt @@ -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() } } } diff --git a/src/test/kotlin/com/hiczp/bilibili/api/test/SeasonTest.kt b/src/test/kotlin/com/hiczp/bilibili/api/test/SeasonTest.kt new file mode 100644 index 0000000..19bd136 --- /dev/null +++ b/src/test/kotlin/com/hiczp/bilibili/api/test/SeasonTest.kt @@ -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() + } + } +} diff --git a/src/test/kotlin/com/hiczp/bilibili/api/test/UserInfoTest.kt b/src/test/kotlin/com/hiczp/bilibili/api/test/UserInfoTest.kt index 3e2396d..d1ae123 100644 --- a/src/test/kotlin/com/hiczp/bilibili/api/test/UserInfoTest.kt +++ b/src/test/kotlin/com/hiczp/bilibili/api/test/UserInfoTest.kt @@ -7,7 +7,7 @@ class UserInfoTest { @Test fun info() { runBlocking { - Config.bilibiliClient.appAPI.myInfo().await() + bilibiliClient.appAPI.myInfo().await() } } }