diff --git a/.gitignore b/.gitignore index e1f8aba..023e378 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build/ *.log .idea/ .DS_Store +bin/ diff --git a/README.md b/README.md index 75fe6a1..7d69471 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,20 @@ store.setNetease(music.netease.cookie) store.setQq(music.qq.musicid, music.qq.musicKey) ``` +## 错误处理 + +所有公开方法失败时只抛 `MusicApiException`,用 `kind`(`MusicErrorKind`)区分原因,`platformCode` 是平台原始业务码: + +| kind | 含义 | 建议界面 | +|---|---|---| +| `NOT_LOGGED_IN` | 网易云 code=301、QQ code=1000:未登录或登录过期 | 切回未登录,提示重新扫码 | +| `RISK_CONTROL` | 网易云 -460/-461/-462/405:风控、需验证、操作频繁 | 提示稍后再试 | +| `NETWORK` / `TIMEOUT` | 连接失败 / 超过单次请求总时限(默认 30 秒) | 「重试」按钮 | +| `BAD_RESPONSE` | 非 JSON、空 body、结构不符、响应体超过上限 | 显示错误信息 | +| `API_ERROR` | 其它业务错误码 | 显示错误信息 | + +`netease.getLoginUid()` 在 cookie 失效时返回 `null`,可用来校验持久化回来的登录态。 + ## 接管日志(可选) ```kotlin @@ -109,7 +123,7 @@ MusicLog.logger = object : MusicLogger { } ``` -默认无操作,不打印任何东西。 +默认无操作,不打印任何东西。凭证、cookie 值、搜索词不会出现在 info 及以上级别的日志里。 ## 功能矩阵 @@ -128,7 +142,7 @@ MusicLog.logger = object : MusicLogger { ```kotlin repositories { maven("https://jitpack.io") } -dependencies { implementation("com.github.FPSMasterTeam:Cadence:v0.1.1") } +dependencies { implementation("com.github.FPSMasterTeam:Cadence:v0.1.2") } ``` 版本号就是 git tag(带 `v` 前缀)。JitPack 会在首次拉取时按 `jitpack.yml` 现场构建并把坐标 @@ -138,7 +152,7 @@ dependencies { implementation("com.github.FPSMasterTeam:Cadence:v0.1.1") } **发新版**:改 `build.gradle.kts` 里的 `version` → 提交 → 打 tag 并推送,即可被引用: ```bash -git tag v0.1.2 && git push origin v0.1.2 +git tag v0.1.3 && git push origin v0.1.3 ``` 已知消费方:**FPSMaster-Edge**(1.8.9 / Forge,shadow 进 jar)、**FPSMaster-Nova** diff --git a/build.gradle.kts b/build.gradle.kts index 80c0b00..9c32ec1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "top.fpsmaster" -version = "0.1.1" +version = "0.1.2" dependencies { // 唯一的第三方运行时依赖:Gson(JSON 解析)。其余全部走 JDK 内置。 diff --git a/src/main/kotlin/top/fpsmaster/music/JsonExt.kt b/src/main/kotlin/top/fpsmaster/music/JsonExt.kt index deb8a68..d4b5787 100644 --- a/src/main/kotlin/top/fpsmaster/music/JsonExt.kt +++ b/src/main/kotlin/top/fpsmaster/music/JsonExt.kt @@ -1,7 +1,10 @@ package top.fpsmaster.music +import com.google.gson.Gson import com.google.gson.JsonArray +import com.google.gson.JsonElement import com.google.gson.JsonObject +import com.google.gson.JsonParseException /** * 安全的 JSON 成员访问。 @@ -15,3 +18,32 @@ internal fun JsonObject.objOrNull(key: String): JsonObject? = internal fun JsonObject.arrOrNull(key: String): JsonArray? = get(key)?.takeIf { it.isJsonArray }?.asJsonArray + +/** 元素本身是对象时返回它,否则 null(数组里混进 null 或基本类型时不抛异常)。 */ +internal fun JsonElement?.asObjOrNull(): JsonObject? = + this?.takeIf { it.isJsonObject }?.asJsonObject + +/** 基本类型转字符串;JsonNull、对象、数组、缺失都返回 null(`asString` 对它们会抛异常)。 */ +internal fun JsonElement?.strOrNull(): String? = + this?.takeIf { it.isJsonPrimitive }?.asString + +/** 数字或数字字符串转 Int;其它情况返回 null(`asInt` 对 "abc" 会抛 NumberFormatException)。 */ +internal fun JsonElement?.intOrNull(): Int? = + this?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asInt }.getOrNull() } + +internal fun JsonElement?.longOrNull(): Long? = + this?.takeIf { it.isJsonPrimitive }?.let { runCatching { it.asLong }.getOrNull() } + +/** + * 把响应 body 解析成 JSON 对象。空 body、HTML 错误页、顶层不是对象时抛 + * [MusicErrorKind.BAD_RESPONSE],而不是让 Gson 的 JsonSyntaxException / null 漏给宿主。 + */ +internal fun Gson.parseObject(body: String, what: String): JsonObject { + val parsed = try { + fromJson(body, JsonElement::class.java) + } catch (e: JsonParseException) { + throw MusicApiException("$what:响应不是 JSON", e, MusicErrorKind.BAD_RESPONSE) + } + return parsed.asObjOrNull() + ?: throw MusicApiException("$what:响应解析失败", null, MusicErrorKind.BAD_RESPONSE) +} diff --git a/src/main/kotlin/top/fpsmaster/music/MusicModels.kt b/src/main/kotlin/top/fpsmaster/music/MusicModels.kt index 5cee2d9..a65582f 100644 --- a/src/main/kotlin/top/fpsmaster/music/MusicModels.kt +++ b/src/main/kotlin/top/fpsmaster/music/MusicModels.kt @@ -168,5 +168,39 @@ data class QQUserInfo( val avatarUrl: String, ) -/** 音乐 API 调用异常。 */ -class MusicApiException(message: String, cause: Throwable? = null) : RuntimeException(message, cause) +/** + * 音乐 API 失败的分类。宿主按它决定界面:[NOT_LOGGED_IN] 切回未登录并提示重新扫码, + * [RISK_CONTROL] 提示稍后再试,[NETWORK]/[TIMEOUT] 给「重试」,其余显示错误信息。 + */ +enum class MusicErrorKind { + /** 连接失败、连接被重置等网络层错误。 */ + NETWORK, + + /** 超过单次请求总时限。 */ + TIMEOUT, + + /** 平台判定未登录或登录已过期(网易云 code=301 等)。 */ + NOT_LOGGED_IN, + + /** 平台风控 / 需要安全验证 / 操作过于频繁(网易云 -460、-462、405 等)。 */ + RISK_CONTROL, + + /** 响应不是预期的 JSON 结构(HTML 错误页、空 body、字段缺失等)。 */ + BAD_RESPONSE, + + /** 平台返回了明确的业务错误码。 */ + API_ERROR, +} + +/** + * 音乐 API 调用异常。本库公开方法遇到网络、解析、业务错误时都抛这一种异常,宿主只需捕获它。 + * + * @param kind 失败分类,见 [MusicErrorKind]。 + * @param platformCode 平台原始业务码(如网易云 301、QQ 的 req.code),没有时为 null。 + */ +class MusicApiException @JvmOverloads constructor( + message: String, + cause: Throwable? = null, + val kind: MusicErrorKind = MusicErrorKind.API_ERROR, + val platformCode: Int? = null, +) : RuntimeException(message, cause) diff --git a/src/main/kotlin/top/fpsmaster/music/NeteaseMusicApi.kt b/src/main/kotlin/top/fpsmaster/music/NeteaseMusicApi.kt index e5aa60a..a44ef43 100644 --- a/src/main/kotlin/top/fpsmaster/music/NeteaseMusicApi.kt +++ b/src/main/kotlin/top/fpsmaster/music/NeteaseMusicApi.kt @@ -14,7 +14,11 @@ import top.fpsmaster.music.lyric.LyricParser * 登录态:二维码登录成功后 [cookie] 会被写入 `MUSIC_U`,之后的请求自动带上。 * 高音质 / VIP 歌曲需要对应黑胶会员的登录 cookie,否则接口会回退或返回空链接。 * - * 线程安全性:单实例非并发安全,若并发使用请每线程一个实例或自行加锁。 + * 线程安全性:[cookie] 的读写对多线程可见,登录成功后的 cookie 合并在实例锁内完成; + * 业务请求本身无共享可变状态,可以并发调用。 + * + * 错误:所有方法失败时抛 [MusicApiException],按 [MusicApiException.kind] 区分 + * 未登录(code 301)、风控(-460/-462/405)、网络、超时、响应异常。 */ class NeteaseMusicApi( private val http: MusicHttp = MusicHttp(), @@ -23,6 +27,7 @@ class NeteaseMusicApi( private val gson = Gson() /** 完整 cookie 串(如登录后持久化到磁盘再回填)。 */ + @Volatile var cookie: String = initialCookie // ---- 公开 API ---- @@ -34,7 +39,7 @@ class NeteaseMusicApi( mapOf("s" to keyword, "type" to "1", "limit" to limit.toString(), "offset" to offset.toString(), "total" to "true"), ) val songs = data.objOrNull("result")?.arrOrNull("songs") ?: return emptyList() - return songs.mapNotNull { runCatching { parseTrack(it.asJsonObject) }.getOrNull() } + return songs.mapNotNull { el -> el.asObjOrNull()?.let { runCatching { parseTrack(it) }.getOrNull() } } } /** 获取播放直链。 */ @@ -48,16 +53,16 @@ class NeteaseMusicApi( "/weapi/song/enhance/player/url/v1", mapOf("ids" to "[${track.id}]", "level" to level, "encodeType" to "flac"), ) - val item = data.arrOrNull("data")?.firstOrNull()?.asJsonObject + val item = data.arrOrNull("data")?.firstOrNull().asObjOrNull() ?: return SongUrl(null, quality) - val url = item.get("url")?.takeIf { !it.isJsonNull }?.asString - val type = item.get("type")?.takeIf { !it.isJsonNull }?.asString ?: "" - val size = item.get("size")?.takeIf { !it.isJsonNull }?.asLong ?: 0 + val url = item.get("url").strOrNull() + val type = item.get("type").strOrNull() ?: "" + val size = item.get("size").longOrNull() ?: 0 // freeTrialInfo 非空 => 该链接是试听片段(start/end 单位为秒) - val trial = item.objOrNull("freeTrialInfo")?.takeIf { !it.isJsonNull } + val trial = item.objOrNull("freeTrialInfo") return if (trial != null) { - val start = trial.get("start")?.takeIf { !it.isJsonNull }?.asLong ?: 0 - val end = trial.get("end")?.takeIf { !it.isJsonNull }?.asLong ?: 0 + val start = trial.get("start").longOrNull() ?: 0 + val end = trial.get("end").longOrNull() ?: 0 SongUrl(url, quality, type, size, isTrial = true, trialStartMs = start * 1000, trialEndMs = end * 1000) } else { SongUrl(url, quality, type, size) @@ -75,9 +80,9 @@ class NeteaseMusicApi( "/weapi/song/lyric", mapOf("id" to track.id, "lv" to "-1", "kv" to "-1", "tv" to "-1", "yv" to "-1"), ) - val lrc = data.objOrNull("lrc")?.get("lyric")?.takeIf { !it.isJsonNull }?.asString ?: "" - val tlyric = data.objOrNull("tlyric")?.get("lyric")?.takeIf { !it.isJsonNull }?.asString?.ifBlank { null } - val yrc = data.objOrNull("yrc")?.get("lyric")?.takeIf { !it.isJsonNull }?.asString?.ifBlank { null } + val lrc = data.objOrNull("lrc")?.get("lyric").strOrNull() ?: "" + val tlyric = data.objOrNull("tlyric")?.get("lyric").strOrNull()?.ifBlank { null } + val yrc = data.objOrNull("yrc")?.get("lyric").strOrNull()?.ifBlank { null } return Lyric(lrc, tlyric, yrc, LyricParser.parse(lrc, tlyric, yrc)) } @@ -86,14 +91,16 @@ class NeteaseMusicApi( /** 第一步:创建二维码。用 [QrCode.qrContent](一个 URL)渲染二维码图片。 */ fun createQrCode(): QrCode { val keyData = weapi("/weapi/login/qrcode/unikey", mapOf("type" to "1")) - val unikey = keyData.get("unikey")?.asString - ?: throw MusicApiException("网易云:获取 unikey 失败") + val unikey = keyData.get("unikey").strOrNull() + ?: throw MusicApiException("网易云:获取 unikey 失败", null, MusicErrorKind.BAD_RESPONSE) return QrCode(unikey, "https://music.163.com/login?codekey=$unikey") } /** * 第二步:轮询二维码状态。返回 [QrLoginState.CONFIRMED] 时,登录 cookie 已写入 [cookie]。 * 建议每 2~3 秒轮询一次。 + * + * 响应无法解析时返回 [QrLoginState.ERROR];网络失败抛 [MusicApiException]([MusicErrorKind.NETWORK]/[MusicErrorKind.TIMEOUT])。 */ fun checkQrCode(qr: QrCode): QrLoginState { val resp = http.postForm( @@ -102,8 +109,13 @@ class NeteaseMusicApi( headers = baseHeaders(), cookies = buildCookies(cookie), ) - val json = gson.fromJson(resp.body, JsonObject::class.java) - return when (json.get("code")?.asInt) { + val json = try { + gson.parseObject(resp.body, "网易云二维码状态") + } catch (e: MusicApiException) { + MusicLog.warn("网易云二维码状态无法解析: HTTP ${resp.status}") + return QrLoginState.ERROR + } + return when (json.get("code").intOrNull()) { 803 -> { captureCookies(resp.setCookies) QrLoginState.CONFIRMED @@ -115,17 +127,26 @@ class NeteaseMusicApi( } } - /** 是否已登录,返回用户 id(未登录为 null)。 */ + /** + * 是否已登录,返回用户 id(未登录或 cookie 已失效为 null)。 + * + * 宿主可以用它校验持久化回来的 cookie:返回 null 就应把界面切回未登录。 + */ fun getLoginUid(): Long? { - val data = weapi("/weapi/w/nuser/account/get", emptyMap()) - return data.objOrNull("account")?.get("id")?.takeIf { !it.isJsonNull }?.asLong + val data = try { + weapi("/weapi/w/nuser/account/get", emptyMap()) + } catch (e: MusicApiException) { + if (e.kind == MusicErrorKind.NOT_LOGGED_IN) return null + throw e + } + return data.objOrNull("account")?.get("id").longOrNull() } /** 每日推荐歌曲(需登录)。 */ fun getDailyRecommendSongs(): List { val data = weapi("/weapi/v3/discovery/recommend/songs", emptyMap()) val songs = data.objOrNull("data")?.arrOrNull("dailySongs") ?: return emptyList() - return songs.mapNotNull { runCatching { parseTrack(it.asJsonObject) }.getOrNull() } + return songs.mapNotNull { el -> el.asObjOrNull()?.let { runCatching { parseTrack(it) }.getOrNull() } } } /** 用户歌单列表(需登录)。 */ @@ -136,12 +157,12 @@ class NeteaseMusicApi( ) val list = data.arrOrNull("playlist") ?: return emptyList() return list.mapNotNull { - val o = it.asJsonObject + val o = it.asObjOrNull() ?: return@mapNotNull null PlaylistBrief( - id = o.get("id")?.asString ?: return@mapNotNull null, - name = o.get("name")?.asString ?: "", - coverUrl = o.get("coverImgUrl")?.takeIf { c -> !c.isJsonNull }?.asString, - trackCount = o.get("trackCount")?.asInt ?: 0, + id = o.get("id").strOrNull() ?: return@mapNotNull null, + name = o.get("name").strOrNull() ?: "", + coverUrl = o.get("coverImgUrl").strOrNull(), + trackCount = o.get("trackCount").intOrNull() ?: 0, ) } } @@ -153,7 +174,7 @@ class NeteaseMusicApi( mapOf("id" to playlistId, "n" to limit.toString(), "s" to "8"), ) val tracks = data.objOrNull("playlist")?.arrOrNull("tracks") ?: return emptyList() - return tracks.mapNotNull { runCatching { parseTrack(it.asJsonObject) }.getOrNull() } + return tracks.mapNotNull { el -> el.asObjOrNull()?.let { runCatching { parseTrack(it) }.getOrNull() } } } fun clearLogin() { @@ -164,7 +185,7 @@ class NeteaseMusicApi( private fun parseTrack(o: JsonObject): Track { val artists = (o.arrOrNull("ar") ?: o.arrOrNull("artists")) - ?.mapNotNull { it.asJsonObject.get("name")?.asString } + ?.mapNotNull { it.asObjOrNull()?.get("name").strOrNull() } ?.joinToString(" / ") ?: "" val albumObj = o.objOrNull("al") ?: o.objOrNull("album") // fee: 0=免费, 1=VIP专享, 4=数字专辑(需购买), 8=免费低音质/高音质需VIP。 @@ -200,11 +221,17 @@ class NeteaseMusicApi( return RawResult(resp.body, resp.setCookies.entries.joinToString("; ") { "${it.key}=${it.value}" }) } - /** 发一次 weapi 请求并返回响应 JSON。 */ + /** + * 发一次 weapi 请求并返回响应 JSON。 + * + * 网易云的业务错误是 HTTP 200 + body 里的 `code`(301 未登录、-460 风控……), + * 不检查就会把「登录过期」「被风控」当成空列表静默返回。这里统一翻成 [MusicApiException]。 + */ private fun weapi(path: String, params: Map): JsonObject { val resp = postWeapi(path, params, buildCookies(cookie)) - return gson.fromJson(resp.body, JsonObject::class.java) - ?: throw MusicApiException("网易云:响应解析失败 ($path)") + val json = gson.parseObject(resp.body, "网易云 $path") + checkNeteaseCode(json, path) + return json } private fun postWeapi(path: String, params: Map, cookies: Map): MusicHttp.Response = @@ -231,6 +258,7 @@ class NeteaseMusicApi( return base } + @Synchronized private fun captureCookies(set: Map) { // 至少需要 MUSIC_U;把新 cookie 合并进现有串 val merged = LinkedHashMap() @@ -243,6 +271,24 @@ class NeteaseMusicApi( } } +/** + * 把网易云 body 里的业务码翻成 [MusicApiException]。没有 `code` 字段的响应按成功处理。 + */ +internal fun checkNeteaseCode(json: JsonObject, path: String) { + val code = json.get("code").intOrNull() ?: return + if (code == 200) return + val kind = when (code) { + 301 -> MusicErrorKind.NOT_LOGGED_IN + -460, -461, -462, 405 -> MusicErrorKind.RISK_CONTROL + else -> MusicErrorKind.API_ERROR + } + val detail = (json.get("message").strOrNull() ?: json.get("msg").strOrNull())?.takeIf { it.isNotBlank() } + throw MusicApiException( + "网易云:接口返回 code=$code" + (detail?.let { "($it)" } ?: "") + " ($path)", + null, kind, code, + ) +} + /** 歌单摘要。 */ data class PlaylistBrief( val id: String, diff --git a/src/main/kotlin/top/fpsmaster/music/QQMusicApi.kt b/src/main/kotlin/top/fpsmaster/music/QQMusicApi.kt index e3b7b85..4674c15 100644 --- a/src/main/kotlin/top/fpsmaster/music/QQMusicApi.kt +++ b/src/main/kotlin/top/fpsmaster/music/QQMusicApi.kt @@ -8,6 +8,7 @@ import top.fpsmaster.music.http.MusicHttp import top.fpsmaster.music.lyric.LyricParser import java.util.Base64 import java.util.UUID +import java.util.concurrent.atomic.AtomicLong /** * QQ 音乐客户端。走 web 端 `u.y.qq.com/cgi-bin/musicu.fcg`(不签名) / `musics.fcg`(zzc 签名)。 @@ -19,6 +20,11 @@ import java.util.UUID * * 说明:QQ 的扫码登录(ptlogin)参数会随腾讯改版漂移,上线前务必真机联调; * 若某天登录失效,优先核对 [createQrCode]/[checkQrCode] 里的 aid/daid/pt_3rd_aid 等常量。 + * + * 线程安全性:凭证字段对多线程可见;每次 [createQrCode] 新建一个独立的登录会话(cookie jar), + * 对同一张二维码的 [checkQrCode] 串行执行,刷新二维码不会和仍在请求中的旧轮询共用 jar。 + * + * 错误:所有方法失败时抛 [MusicApiException],按 [MusicApiException.kind] 区分。 */ class QQMusicApi( private val http: MusicHttp = MusicHttp(), @@ -27,14 +33,28 @@ class QQMusicApi( // 数字 guid(对齐 Meting:Math.random()*1e10),QQ vkey 会按此绑定凭证 private val guid: String = (1_000_000_000L + kotlin.random.Random.nextLong(9_000_000_000L)).toString() + @Volatile var musicid: String = "" + @Volatile var musicKey: String = "" val loggedIn: Boolean get() = musicid.isNotEmpty() && musicKey.isNotEmpty() - // 整个登录流程共享的 cookie jar(模拟浏览器 Session,跨 ptqrshow/ptqrlogin/ - // check_sig/authorize 累积,合并时跳过空值——这是拿到 code 的关键)。 - private val loginJar = LinkedHashMap() + /** + * 一次扫码登录的会话:cookie jar 模拟浏览器 Session,跨 ptqrshow/ptqrlogin/check_sig/authorize + * 累积,合并时跳过空值——这是拿到 code 的关键。每张二维码一个实例,旧轮询线程手里的会话 + * 不会被新二维码清空或混入。 + */ + private class LoginSession { + val jar = LinkedHashMap() + var qrsig: String = "" + } + + @Volatile + private var loginSession: LoginSession? = null + + /** 串行化"切换当前会话"与"凭证提交":防止 clearLogin/新二维码 与旧轮询的写入交错。 */ + private val loginLock = Any() // ---- 公开 API ---- @@ -51,8 +71,8 @@ class QQMusicApi( overrideComm = mapOf("ct" to "6", "cv" to "80600", "tmeAppID" to "qqmusic"), ) val list = data.objOrNull("body")?.objOrNull("song")?.arrOrNull("list") ?: return emptyList() - MusicLog.info("QQ search '$keyword': got ${list.size()} 首") - return list.mapNotNull { runCatching { parseTrack(it.asJsonObject) }.getOrNull() } + MusicLog.debug("QQ search: got ${list.size()} 首") + return parseTracks(list) } /** 推荐歌单(发现页)。 */ @@ -64,14 +84,14 @@ class QQMusicApi( ) val list = data.arrOrNull("List") ?: return emptyList() return list.mapNotNull { el -> - val basic = el.asJsonObject.objOrNull("Playlist")?.objOrNull("basic") ?: return@mapNotNull null + val basic = el.asObjOrNull()?.objOrNull("Playlist")?.objOrNull("basic") ?: return@mapNotNull null val cover = basic.objOrNull("cover") MusicPlaylist( source = MusicSource.QQ, - id = basic.get("tid")?.takeIf { !it.isJsonNull }?.asString ?: return@mapNotNull null, - name = basic.get("title")?.takeIf { !it.isJsonNull }?.asString ?: "", - coverUrl = (cover?.get("medium_url") ?: cover?.get("small_url"))?.takeIf { !it.isJsonNull }?.asString, - trackCount = basic.get("song_cnt")?.takeIf { !it.isJsonNull }?.asInt ?: 0, + id = basic.get("tid").strOrNull() ?: return@mapNotNull null, + name = basic.get("title").strOrNull() ?: "", + coverUrl = cover?.get("medium_url").strOrNull() ?: cover?.get("small_url").strOrNull(), + trackCount = basic.get("song_cnt").intOrNull() ?: 0, ) } } @@ -84,7 +104,7 @@ class QQMusicApi( reqKey = "req", overrideComm = mapOf("ct" to "24", "cv" to "0"), ) val list = data.arrOrNull("songlist") ?: return emptyList() - return list.mapNotNull { runCatching { parseTrack(it.asJsonObject) }.getOrNull() } + return parseTracks(list) } /** @@ -98,8 +118,8 @@ class QQMusicApi( param = mapOf("topId" to topId, "offset" to 0, "num" to num), ) val list = data.arrOrNull("songInfoList") ?: return emptyList() - MusicLog.info("QQ toplist $topId: ${list.size()} 首") - return list.mapNotNull { runCatching { parseTrack(it.asJsonObject) }.getOrNull() } + MusicLog.debug("QQ toplist $topId: ${list.size()} 首") + return parseTracks(list) } /** @@ -117,12 +137,12 @@ class QQMusicApi( headers = mapOf("Referer" to "https://y.qq.com/"), cookies = credentialCookies(), ) - val djson = runCatching { gson.fromJson(detail.body, JsonObject::class.java) }.getOrNull() - val song = djson?.arrOrNull("data")?.firstOrNull()?.asJsonObject + val djson = runCatching { gson.parseObject(detail.body, "QQ 歌曲详情") }.getOrNull() + val song = djson?.arrOrNull("data")?.firstOrNull().asObjOrNull() ?: return SongUrl(null, quality, reason = "获取歌曲信息失败") val file = song.objOrNull("file") - val mediaMid = file?.get("media_mid")?.takeIf { !it.isJsonNull }?.asString ?: mid - val songType = song.get("type")?.takeIf { !it.isJsonNull }?.asInt ?: 0 + val mediaMid = file?.get("media_mid").strOrNull() ?: mid + val songType = song.get("type").intOrNull() ?: 0 // 2. 按请求档位由高到低构造候选音质(只保留该歌确实有的文件) data class QCand(val sizeKey: String, val prefix: String, val ext: String, val q: AudioQuality) @@ -134,7 +154,7 @@ class QQMusicApi( ) val start = when (quality) { AudioQuality.LOSSLESS -> 0; AudioQuality.HIGH -> 1; AudioQuality.STANDARD -> 2 } val cands = all.subList(start, all.size) - .filter { file != null && (file.get(it.sizeKey)?.takeIf { s -> !s.isJsonNull }?.asLong ?: 0L) > 0L } + .filter { file != null && (file.get(it.sizeKey).longOrNull() ?: 0L) > 0L } if (cands.isEmpty()) return SongUrl(null, quality, reason = "无可用音频") // 3. GetVkeyServer/CgiGetVkey:filename = prefix + media_mid + ext(一次请求所有候选音质) @@ -149,25 +169,28 @@ class QQMusicApi( "platform" to "20", )) val infos = data.arrOrNull("midurlinfo") - val sip = (data.arrOrNull("sip")?.firstOrNull()?.asString ?: "https://dl.stream.qqmusic.qq.com/") + val sip = (data.arrOrNull("sip")?.firstOrNull().strOrNull() ?: "https://dl.stream.qqmusic.qq.com/") .replaceFirst("http://", "https://") // 避免混合内容被拦 + // midurlinfo 条数可能少于候选音质数,按实际条数取,越界的候选视为不可用 + val infoCount = infos?.size() ?: 0 for (i in cands.indices) { - val purl = infos?.get(i)?.asJsonObject?.get("purl")?.takeIf { !it.isJsonNull }?.asString + if (i >= infoCount) break + val purl = infos?.get(i).asObjOrNull()?.get("purl").strOrNull() if (!purl.isNullOrEmpty()) { - MusicLog.info("QQ vkey $mid: ${cands[i].prefix} ok") + MusicLog.debug("QQ vkey $mid: ${cands[i].prefix} ok") return SongUrl(sip + purl, cands[i].q, cands[i].ext) } } // 无 purl:按 result 码 / 标志位解析原因 - val info0 = infos?.firstOrNull()?.asJsonObject - val result = info0?.get("result")?.takeIf { !it.isJsonNull }?.asInt ?: -1 - val needBuy = info0?.get("pneedbuy")?.takeIf { !it.isJsonNull }?.asInt == 1 + val info0 = infos?.firstOrNull().asObjOrNull() + val result = info0?.get("result").intOrNull() ?: -1 + val needBuy = info0?.get("pneedbuy").intOrNull() == 1 val reason = when { result == 104003 -> "需要绿钻会员(或无版权)" needBuy || track.vip -> "需要 QQ 音乐会员/付费" else -> "暂时无法播放" } - MusicLog.info("QQ vkey $mid: 无 purl, result=$result → $reason") + MusicLog.debug("QQ vkey $mid: 无 purl, result=$result → $reason") return SongUrl(null, quality, reason = reason) } @@ -188,10 +211,10 @@ class QQMusicApi( val jsonText = resp.body.trim().let { if (it.startsWith("{")) it else it.substringAfter('(').substringBeforeLast(')') } - val json = runCatching { gson.fromJson(jsonText, JsonObject::class.java) }.getOrNull() + val json = runCatching { gson.parseObject(jsonText, "QQ 歌词") }.getOrNull() ?: return Lyric("") fun decode(field: String): String? { - val raw = json.get(field)?.takeIf { !it.isJsonNull }?.asString ?: return null + val raw = json.get(field).strOrNull() ?: return null if (raw.isBlank()) return null // nobase64=1 通常已是明文;个别情况仍是 base64,做一次兜底解码 return runCatching { String(Base64.getDecoder().decode(raw), Charsets.UTF_8) } @@ -212,9 +235,9 @@ class QQMusicApi( cookies = credentialCookies(), ) val text = resp.body.let { if (it.startsWith("callback(") || it.contains(")(")) it.substringAfter('(').substringBeforeLast(')') else it } - val json = runCatching { gson.fromJson(text, JsonObject::class.java) }.getOrNull() + val json = runCatching { gson.parseObject(text, "QQ 用户信息") }.getOrNull() val creator = json?.objOrNull("data")?.objOrNull("creator") - var nick = creator?.get("nick")?.takeIf { !it.isJsonNull }?.asString ?: "QQ用户$musicid" + var nick = creator?.get("nick").strOrNull() ?: "QQ用户$musicid" // 昵称可能是 base64(多为中文名)。仅当解码结果是干净文本时才采用, // 否则像 "SuperSkidder" 这类普通名会被误解码成乱码。 if (nick.matches(Regex("^[A-Za-z0-9+/]+={0,2}$")) && nick.length % 4 == 0) { @@ -223,7 +246,7 @@ class QQMusicApi( ?.let { nick = it } } // 头像用响应里的 headpic(真实头像),没有再回退到 qlogo - val headpic = creator?.get("headpic")?.takeIf { !it.isJsonNull }?.asString + val headpic = creator?.get("headpic").strOrNull() val avatar = headpic?.takeIf { it.isNotBlank() } ?: "https://q1.qlogo.cn/g?b=qq&nk=$musicid&s=100" return QQUserInfo(musicid, nick, avatar) } @@ -237,13 +260,16 @@ class QQMusicApi( * [QrCode.key] 是 qrsig,用于 [checkQrCode] 轮询。 */ fun createQrCode(): QrCode { - loginJar.clear() + val session = LoginSession() // ptqrshow:拿二维码 + qrsig(最小流程,不做 initparams/xlogin) - val show = jarGet("https://ssl.ptlogin2.qq.com/ptqrshow", mapOf( + val show = jarGet(session, "https://ssl.ptlogin2.qq.com/ptqrshow", mapOf( "appid" to "716027609", "e" to "2", "l" to "M", "s" to "3", "d" to "72", "v" to "4", "t" to "0.$loginNonce", "daid" to "383", "pt_3rd_aid" to "100497308", ), "https://xui.ptlogin2.qq.com/") - val qrsig = loginJar["qrsig"] ?: throw MusicApiException("QQ:获取 qrsig 失败") + val qrsig = session.jar["qrsig"] + ?: throw MusicApiException("QQ:获取 qrsig 失败", null, MusicErrorKind.BAD_RESPONSE) + session.qrsig = qrsig + synchronized(loginLock) { loginSession = session } val dataUrl = "data:image/png;base64," + Base64.getEncoder().encodeToString(show.bytes) return QrCode(qrsig, dataUrl) } @@ -251,9 +277,20 @@ class QQMusicApi( /** * 第二步:轮询二维码状态。返回 [QrLoginState.CONFIRMED] 时已完成登录, * [musicid]/[musicKey] 已写入。建议每 2~3 秒轮询一次。 + * + * [qr] 已被新的 [createQrCode] 取代或已 [clearLogin] 时返回 [QrLoginState.EXPIRED], + * 旧轮询线程据此退出,不会动到新二维码的会话。请求进行中才被取代也一样: + * 授权换到的凭证只在会话仍是当前会话时才写入,否则丢弃并返回 EXPIRED。 */ fun checkQrCode(qr: QrCode): QrLoginState { - val resp = jarGet("https://ssl.ptlogin2.qq.com/ptqrlogin", mapOf( + val session = loginSession?.takeIf { it.qrsig == qr.key } ?: return QrLoginState.EXPIRED + synchronized(session) { + return checkQrCodeLocked(session, qr) + } + } + + private fun checkQrCodeLocked(session: LoginSession, qr: QrCode): QrLoginState { + val resp = jarGet(session, "https://ssl.ptlogin2.qq.com/ptqrlogin", mapOf( "u1" to "https://graph.qq.com/oauth2.0/login_jump", "ptqrtoken" to QQHash.hash33(qr.key).toString(), "ptredirect" to "0", "h" to "1", "t" to "1", "g" to "1", "from_ui" to "1", @@ -261,6 +298,8 @@ class QQMusicApi( "js_type" to "1", "login_sig" to "", "pt_uistyle" to "40", "aid" to "716027609", "daid" to "383", "pt_3rd_aid" to "100497308", "has_onekey" to "1", ), "https://xui.ptlogin2.qq.com/") + // 请求期间已被新二维码取代或被取消:结果不再属于当前会话 + if (loginSession !== session) return QrLoginState.EXPIRED val args = PTUI_CB.find(resp.body)?.groupValues?.get(1) ?.let { PTUI_ARG.findAll(it).map { m -> m.groupValues[1] }.toList() } ?: return QrLoginState.ERROR @@ -272,7 +311,12 @@ class QQMusicApi( val sigx = SIGX_RE.find(url)?.groupValues?.get(1) ?: return QrLoginState.ERROR val uin = UIN_RE.find(url)?.groupValues?.get(1) ?: return QrLoginState.ERROR try { - authorizeQq(uin, sigx) + val (id, key) = authorizeQq(session, uin, sigx) + // 授权要串行请求多次,期间用户可能取消/登出/刷新二维码:只有会话仍是当前会话才提交 + synchronized(loginLock) { + if (loginSession !== session) return QrLoginState.EXPIRED + musicid = id; musicKey = key + } QrLoginState.CONFIRMED } catch (e: Exception) { MusicLog.error("QQ 授权失败", e) @@ -287,14 +331,15 @@ class QQMusicApi( } fun clearLogin() { - musicid = ""; musicKey = ""; loginJar.clear() + synchronized(loginLock) { musicid = ""; musicKey = ""; loginSession = null } } // ---- 登录收尾:check_sig → login_jump → authorize → code → qm_keyst ---- - private fun authorizeQq(uin: String, sigx: String) { + /** 走完授权链,返回 (musicid, musickey);不写字段,由调用方确认会话仍有效后提交。 */ + private fun authorizeQq(session: LoginSession, uin: String, sigx: String): Pair { // 4. check_sig(ptredirect=100 让它直接回 p_skey 而非跳转),单次 GET,跳空值合并 - jarGet("https://ssl.ptlogin2.graph.qq.com/check_sig", mapOf( + jarGet(session, "https://ssl.ptlogin2.graph.qq.com/check_sig", mapOf( "uin" to uin, "pttype" to "1", "service" to "ptqrlogin", "nodirect" to "0", "ptsigx" to sigx, "s_url" to "https://graph.qq.com/oauth2.0/login_jump", "ptlang" to "2052", "ptredirect" to "100", "aid" to "716027609", "daid" to "383", @@ -302,13 +347,13 @@ class QQMusicApi( "pt_login_type" to "3", "pt_aid" to "0", "pt_aaid" to "16", "pt_light" to "0", "pt_3rd_aid" to "100497308", ), "https://xui.ptlogin2.qq.com/").also { - MusicLog.info("QQ check_sig: status=${it.status}, setCookies=${it.setCookies.keys}, jar=${loginJar.keys}") + MusicLog.debug("QQ check_sig: status=${it.status}, setCookies=${it.setCookies.keys}, jar=${session.jar.keys}") } - val pSkey = loginJar["p_skey"] ?: throw MusicApiException("QQ:获取 p_skey 失败") - MusicLog.info("QQ authorize: jar=${loginJar.keys}") + val pSkey = session.jar["p_skey"] ?: throw MusicApiException("QQ:获取 p_skey 失败") + MusicLog.debug("QQ authorize: jar=${session.jar.keys}") // 5. authorize POST → 302 到带 code 的回调 - val authorize = jarPostForm("https://graph.qq.com/oauth2.0/authorize", mapOf( + val authorize = jarPostForm(session, "https://graph.qq.com/oauth2.0/authorize", mapOf( "response_type" to "code", "client_id" to "100497308", "redirect_uri" to "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/", "scope" to "get_user_info,get_app_friends", "state" to "state", "switch" to "", "from_ptlogin" to "1", @@ -316,7 +361,7 @@ class QQMusicApi( "g_tk" to QQHash.hash33(pSkey, 5381).toString(), "auth_time" to loginNonce, "ui" to UUID.randomUUID().toString(), ), "https://graph.qq.com/") - val code = followForCode(authorize) + val code = followForCode(session, authorize) // QQLogin:code → musicid + musickey val data = musicu( @@ -325,9 +370,10 @@ class QQMusicApi( param = mapOf("code" to code), extraComm = mapOf("tmeLoginType" to 2), ) - musicid = data.get("musicid")?.takeIf { !it.isJsonNull }?.asString ?: "" - musicKey = data.get("musickey")?.takeIf { !it.isJsonNull }?.asString ?: "" - if (!loggedIn) throw MusicApiException("QQ:登录换取凭证失败") + val id = data.get("musicid").strOrNull() ?: "" + val key = data.get("musickey").strOrNull() ?: "" + if (id.isEmpty() || key.isEmpty()) throw MusicApiException("QQ:登录换取凭证失败") + return id to key } /** @@ -335,35 +381,35 @@ class QQMusicApi( * 这是登录成功的关键——否则后续请求的清除会把好好的会话 cookie 覆盖成空, * 导致 graph.qq.com 认为未登录、弹出 show?which=Login。 */ - private fun mergeIntoJar(setCookies: Map) { - setCookies.forEach { (k, v) -> if (v.isNotEmpty()) loginJar[k] = v } + private fun mergeIntoJar(session: LoginSession, setCookies: Map) { + setCookies.forEach { (k, v) -> if (v.isNotEmpty()) session.jar[k] = v } } - /** GET 并把响应 Set-Cookie 累积回共享 jar(模拟浏览器 Session,跳过空值)。 */ - private fun jarGet(url: String, query: Map, referer: String): MusicHttp.Response { - val r = http.get(url, query, headers = mapOf("Referer" to referer), cookies = loginJar) - mergeIntoJar(r.setCookies) + /** GET 并把响应 Set-Cookie 累积回会话 jar(模拟浏览器 Session,跳过空值)。 */ + private fun jarGet(session: LoginSession, url: String, query: Map, referer: String): MusicHttp.Response { + val r = http.get(url, query, headers = mapOf("Referer" to referer), cookies = session.jar) + mergeIntoJar(session, r.setCookies) return r } - private fun jarPostForm(url: String, form: Map, referer: String): MusicHttp.Response { - val r = http.postForm(url, form, headers = mapOf("Referer" to referer), cookies = loginJar) - mergeIntoJar(r.setCookies) + private fun jarPostForm(session: LoginSession, url: String, form: Map, referer: String): MusicHttp.Response { + val r = http.postForm(url, form, headers = mapOf("Referer" to referer), cookies = session.jar) + mergeIntoJar(session, r.setCookies) return r } /** 从 authorize 响应跟随重定向链,直到 Location/body 出现授权 code。 */ - private fun followForCode(start: MusicHttp.Response): String { + private fun followForCode(session: LoginSession, start: MusicHttp.Response): String { var status = start.status var loc: String? = start.header("Location") var body = start.body - MusicLog.info("QQ authorize resp: status=${start.status}, firstLoc='${(loc ?: "").take(220)}'") + MusicLog.debug("QQ authorize resp: status=${start.status}, hasLocation=${loc != null}") CODE_RE.find(loc ?: "")?.let { return it.groupValues[1] } var hops = 0 while (loc != null && hops < 6) { val url = if (loc.startsWith("http")) loc else "https://graph.qq.com$loc" - val r = jarGet(url, emptyMap(), "https://graph.qq.com/") + val r = jarGet(session, url, emptyMap(), "https://graph.qq.com/") status = r.status body = r.body loc = r.header("Location") @@ -372,10 +418,9 @@ class QQMusicApi( hops++ } val title = Regex("(.*?)", RegexOption.IGNORE_CASE).find(body)?.groupValues?.get(1) ?: "" - val bodyClean = body.replace(Regex("\\s+"), " ").take(400) - throw MusicApiException( - "QQ:获取授权 code 失败 (status=$status, title='$title', lastLoc='${(loc ?: "").take(160)}', body='$bodyClean')" - ) + // 响应原文和跳转地址可能带会话参数,只进 debug 日志,不进异常信息(会被界面展示、随日志上传) + MusicLog.debug("QQ authorize 失败详情: lastLoc='${(loc ?: "").substringBefore('?')}', bodyLength=${body.length}") + throw MusicApiException("QQ:获取授权 code 失败 (status=$status, title='$title')") } // ---- 内部:musicu / musics 请求 ---- @@ -403,17 +448,21 @@ class QQMusicApi( headers = mapOf("Referer" to "https://y.qq.com/"), cookies = credentialCookies(), ) - val json = gson.fromJson(resp.body, JsonObject::class.java) - ?: throw MusicApiException("QQ:响应解析失败 ($module.$method)") - val req = json.getAsJsonObject(reqKey) ?: throw MusicApiException("QQ:响应缺少 $reqKey") - val code = req.get("code")?.asInt ?: -1 - if (code != 0) throw MusicApiException("QQ:接口返回 code=$code ($module.$method)") + val json = gson.parseObject(resp.body, "QQ $module.$method") + val req = json.objOrNull(reqKey) + ?: throw MusicApiException("QQ:响应缺少 $reqKey ($module.$method)", null, MusicErrorKind.BAD_RESPONSE) + val code = req.get("code").intOrNull() ?: -1 + if (code != 0) { + // 1000 = 登录态失效(qm_keyst 过期) + val kind = if (code == 1000) MusicErrorKind.NOT_LOGGED_IN else MusicErrorKind.API_ERROR + throw MusicApiException("QQ:接口返回 code=$code ($module.$method)", null, kind, code) + } return req.objOrNull("data") ?: JsonObject() } /** vkey 请求:GET musicu.fcg?data=,payload 仅 req_0、无 comm、不签名。 */ private fun vkeyRequest(param: Map): JsonObject { - MusicLog.info("QQ vkey req: cookies=${credentialCookies().keys}, keystPrefix='${musicKey.take(6)}' guid=$guid") + MusicLog.debug("QQ vkey req: loggedIn=$loggedIn") val payload = JsonObject().apply { add("req_0", gson.toJsonTree(mapOf("module" to "vkey.GetVkeyServer", "method" to "CgiGetVkey", "param" to param))) } @@ -423,7 +472,7 @@ class QQMusicApi( headers = mapOf("Referer" to "https://y.qq.com/"), cookies = credentialCookies(), ) - val json = gson.fromJson(resp.body, JsonObject::class.java) ?: return JsonObject() + val json = gson.parseObject(resp.body, "QQ vkey") return json.objOrNull("req_0")?.objOrNull("data") ?: JsonObject() } @@ -450,11 +499,14 @@ class QQMusicApi( ) } + private fun parseTracks(list: com.google.gson.JsonArray): List = + list.mapNotNull { el -> el.asObjOrNull()?.let { runCatching { parseTrack(it) }.getOrNull() } } + private fun parseTrack(o: JsonObject): Track { - val mid = (o.get("mid") ?: o.get("songmid"))?.takeIf { !it.isJsonNull }?.asString - val id = (o.get("id") ?: o.get("songid"))?.takeIf { !it.isJsonNull }?.asString ?: (mid ?: "") + val mid = (o.get("mid") ?: o.get("songmid")).strOrNull() + val id = (o.get("id") ?: o.get("songid")).strOrNull() ?: (mid ?: "") val singers = o.arrOrNull("singer") - ?.mapNotNull { it.asJsonObject.get("name")?.asString } + ?.mapNotNull { it.asObjOrNull()?.get("name").strOrNull() } ?.joinToString(" / ") ?: "" val album = o.objOrNull("album") val albumName = album?.get("name")?.takeIf { !it.isJsonNull }?.asString @@ -480,9 +532,9 @@ class QQMusicApi( } /** 每次读取都递增,避免依赖 System.currentTimeMillis 的可测试性;用于 t/action/_ 等抗缓存参数。 */ - private var nonceCounter = 0L + private val nonceCounter = AtomicLong() private val loginNonce: String - get() = (System.currentTimeMillis() + nonceCounter++).toString() + get() = (System.currentTimeMillis() + nonceCounter.getAndIncrement()).toString() companion object { private val PTUI_CB = Regex("""ptuiCB\((.*?)\)""") diff --git a/src/main/kotlin/top/fpsmaster/music/http/MusicHttp.kt b/src/main/kotlin/top/fpsmaster/music/http/MusicHttp.kt index 3de4767..ac08d90 100644 --- a/src/main/kotlin/top/fpsmaster/music/http/MusicHttp.kt +++ b/src/main/kotlin/top/fpsmaster/music/http/MusicHttp.kt @@ -1,18 +1,39 @@ package top.fpsmaster.music.http +import top.fpsmaster.music.MusicApiException +import top.fpsmaster.music.MusicErrorKind +import java.io.ByteArrayOutputStream import java.io.IOException +import java.io.InputStream import java.net.HttpURLConnection +import java.net.SocketTimeoutException import java.net.URL import java.net.URLEncoder import java.nio.charset.StandardCharsets +import java.util.concurrent.TimeUnit /** * 轻量 HTTP 助手,基于 JDK 内置 [HttpURLConnection],零第三方依赖,**兼容 Java 8**。 * * 特意关闭自动重定向(QQ 登录流程需要读取 302 的 Location 与 Set-Cookie), * 并暴露原始响应头,便于手动处理 cookie。 + * + * 类与请求方法为 open,便于测试替换网络层。 + * + * 失败一律抛 [MusicApiException]:网络错误为 [MusicErrorKind.NETWORK],超过总时限为 + * [MusicErrorKind.TIMEOUT],响应体超限为 [MusicErrorKind.BAD_RESPONSE]。HTTP 4xx/5xx 不算失败, + * 照常返回 [Response] 由调用方按状态码处理。 */ -class MusicHttp { +open class MusicHttp @JvmOverloads constructor( + /** 建立 TCP/TLS 连接的超时。 */ + private val connectTimeoutMs: Int = 10_000, + /** 两次 read 之间的最长间隔。 */ + private val readTimeoutMs: Int = 15_000, + /** 单次请求的总时限:读响应体时超过即失败(最坏再多等一个 readTimeout)。 */ + private val totalTimeoutMs: Long = 30_000, + /** 响应体大小上限。 */ + private val maxBodyBytes: Int = 16 * 1024 * 1024, +) { data class Response( val status: Int, @@ -41,7 +62,7 @@ class MusicHttp { headers.entries.firstOrNull { it.key.equals(name, true) }?.value?.firstOrNull() } - fun get( + open fun get( url: String, query: Map = emptyMap(), headers: Map = emptyMap(), @@ -51,7 +72,7 @@ class MusicHttp { return send(full, "GET", null, null, headers, cookies) } - fun postForm( + open fun postForm( url: String, form: Map, headers: Map = emptyMap(), @@ -63,7 +84,7 @@ class MusicHttp { headers = headers, cookies = cookies, ) - fun postJson( + open fun postJson( url: String, json: String, query: Map = emptyMap(), @@ -87,47 +108,88 @@ class MusicHttp { headers: Map, cookies: Map, ): Response { - val conn = URL(url).openConnection() as HttpURLConnection - conn.requestMethod = method - conn.instanceFollowRedirects = false // 手动处理 302,读取 Location + Set-Cookie - conn.connectTimeout = 10_000 - conn.readTimeout = 20_000 - - if ("User-Agent" !in headers) conn.setRequestProperty("User-Agent", DEFAULT_UA) - headers.forEach { (k, v) -> conn.setRequestProperty(k, v) } - if (cookies.isNotEmpty()) { - conn.setRequestProperty("Cookie", cookies.entries.joinToString("; ") { "${it.key}=${it.value}" }) + val conn = try { + URL(url).openConnection() as HttpURLConnection + } catch (e: IOException) { + throw MusicApiException("网络错误:${e.javaClass.simpleName}", e, MusicErrorKind.NETWORK) } - if (contentType != null) conn.setRequestProperty("Content-Type", contentType) + // readTimeout 只限制两次 read 之间的间隔:服务端慢慢吐字节时请求可以无限挂住。 + // 读 body 的循环每次 read 后检查总截止时间(单次 read 最多阻塞 readTimeout), + // 所以一次请求最长约为 connectTimeout + totalTimeout + readTimeout。 + // 不用另起线程 disconnect:新版 JDK 的 disconnect 与阻塞中的 read 抢同一把锁,打断不了。 + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(totalTimeoutMs) + var completed = false + try { + conn.requestMethod = method + conn.instanceFollowRedirects = false // 手动处理 302,读取 Location + Set-Cookie + conn.connectTimeout = connectTimeoutMs + conn.readTimeout = readTimeoutMs + + if ("User-Agent" !in headers) conn.setRequestProperty("User-Agent", DEFAULT_UA) + headers.forEach { (k, v) -> conn.setRequestProperty(k, v) } + if (cookies.isNotEmpty()) { + conn.setRequestProperty("Cookie", cookies.entries.joinToString("; ") { "${it.key}=${it.value}" }) + } + if (contentType != null) conn.setRequestProperty("Content-Type", contentType) - if (body != null) { - conn.doOutput = true - conn.outputStream.use { it.write(body) } - } + if (body != null) { + conn.doOutput = true + conn.outputStream.use { it.write(body) } + } - val status = conn.responseCode - val bytes = readBody(conn, status) - // headerFields 的 key 可能含 null(状态行);过滤掉 - val respHeaders = LinkedHashMap>() - conn.headerFields.forEach { (k, v) -> if (k != null) respHeaders[k] = v } - conn.disconnect() - - return Response( - status = status, - body = String(bytes, StandardCharsets.UTF_8), - bytes = bytes, - headers = respHeaders, - ) + val status = conn.responseCode + val bytes = readBody(conn, status, deadline) + // headerFields 的 key 可能含 null(状态行);过滤掉 + val respHeaders = LinkedHashMap>() + conn.headerFields.forEach { (k, v) -> if (k != null) respHeaders[k] = v } + completed = true + return Response( + status = status, + body = String(bytes, StandardCharsets.UTF_8), + bytes = bytes, + headers = respHeaders, + ) + } catch (e: IOException) { + if (e is SocketTimeoutException) { + throw MusicApiException("请求超时", e, MusicErrorKind.TIMEOUT) + } + throw MusicApiException("网络错误:${e.javaClass.simpleName}", e, MusicErrorKind.NETWORK) + } finally { + // 正常读完并关闭流的连接留给 JDK 的 keep-alive 缓存复用(QQ 登录要串行打 4~9 个请求, + // 每次都 disconnect 就得重新 TLS 握手);中途失败的连接状态未知,必须断开。 + if (!completed) conn.disconnect() + } } - private fun readBody(conn: HttpURLConnection, status: Int): ByteArray { - return try { - val stream = if (status in 200..399) conn.inputStream else conn.errorStream - stream?.use { it.readBytes() } ?: ByteArray(0) + private fun readBody(conn: HttpURLConnection, status: Int, deadline: Long): ByteArray { + val stream = try { + if (status in 200..399) conn.inputStream else conn.errorStream } catch (e: IOException) { // 3xx/4xx 时 inputStream 可能抛异常,退回 errorStream(登录流程只需 header) - runCatching { conn.errorStream?.use { it.readBytes() } }.getOrNull() ?: ByteArray(0) + conn.errorStream + } ?: return ByteArray(0) + return stream.use { readLimited(it, deadline) } + } + + /** + * 读完整个流。超过 [maxBodyBytes] 或总截止时间直接失败,避免异常响应把整块内容读进内存、 + * 或慢速响应把调用线程挂住。 + */ + private fun readLimited(input: InputStream, deadline: Long): ByteArray { + val out = ByteArrayOutputStream() + val buf = ByteArray(8192) + while (true) { + val n = input.read(buf) + if (n < 0) break + if (System.nanoTime() - deadline > 0) { + throw MusicApiException("请求超时(超过 ${totalTimeoutMs / 1000} 秒)", null, MusicErrorKind.TIMEOUT) + } + if (out.size() + n > maxBodyBytes) { + throw MusicApiException("响应体超过 ${maxBodyBytes / (1024 * 1024)} MB 上限", null, MusicErrorKind.BAD_RESPONSE) + } + out.write(buf, 0, n) } + return out.toByteArray() } companion object { diff --git a/src/main/kotlin/top/fpsmaster/music/lyric/LyricParser.kt b/src/main/kotlin/top/fpsmaster/music/lyric/LyricParser.kt index ef01e51..f67b0ae 100644 --- a/src/main/kotlin/top/fpsmaster/music/lyric/LyricParser.kt +++ b/src/main/kotlin/top/fpsmaster/music/lyric/LyricParser.kt @@ -25,7 +25,9 @@ object LyricParser { // YRC 行:[起点ms,时长ms]后跟若干 (字起点,字时长,0)字 private val YRC_LINE = Regex("""^\[(\d+),(\d+)](.*)$""") - private val YRC_WORD = Regex("""\((\d+),(\d+),(\d+)\)([^(]+)""") + // 只匹配时间戳;字文本取到下一个时间戳为止。不能用 `([^(]+)` 匹配文本: + // 和声歌词里的字本身可以是「(」或以「(」开头(如 `(1400,200,0)(`),那样会被整段跳过。 + private val YRC_STAMP = Regex("""\((\d+),(\d+),(\d+)\)""") // LRC 时间戳:[mm:ss.xx] / [mm:ss] / [mm:ss:xx](分/秒位数放宽) private val LRC_TIME = Regex("""\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)]""") @@ -65,7 +67,9 @@ object LyricParser { return withDuration.map { line -> val t = transMap[line.startMs] - ?: transMap.entries.firstOrNull { abs(it.key - line.startMs) < 500 }?.value + ?: transMap.entries + .filter { abs(it.key - line.startMs) < 500 } + .minByOrNull { abs(it.key - line.startMs) }?.value if (t != null) line.copy(translation = t) else line } } @@ -95,10 +99,13 @@ object LyricParser { val words = ArrayList() val sb = StringBuilder() - for (w in YRC_WORD.findAll(rest)) { + val stamps = YRC_STAMP.findAll(rest).toList() + for ((i, w) in stamps.withIndex()) { + val textEnd = if (i + 1 < stamps.size) stamps[i + 1].range.first else rest.length + val wText = rest.substring(w.range.last + 1, textEnd) + if (wText.isEmpty()) continue val wStart = w.groupValues[1].toLong() val wDur = w.groupValues[2].toLong() - val wText = w.groupValues[4] words.add(LyricWord(wStart, wDur, wText)) sb.append(wText) } diff --git a/src/main/kotlin/top/fpsmaster/music/store/MusicCredentialStore.kt b/src/main/kotlin/top/fpsmaster/music/store/MusicCredentialStore.kt index 8ee70a5..0806308 100644 --- a/src/main/kotlin/top/fpsmaster/music/store/MusicCredentialStore.kt +++ b/src/main/kotlin/top/fpsmaster/music/store/MusicCredentialStore.kt @@ -3,19 +3,22 @@ package top.fpsmaster.music.store import com.google.gson.GsonBuilder import com.google.gson.JsonObject import top.fpsmaster.music.MusicLog +import java.nio.charset.StandardCharsets +import java.nio.file.AtomicMoveNotSupportedException import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +import java.nio.file.StandardCopyOption +import java.nio.file.attribute.PosixFilePermissions import kotlin.io.path.exists import kotlin.io.path.isRegularFile import kotlin.io.path.reader -import kotlin.io.path.writer /** * 音乐登录凭证的磁盘持久化(可选工具,不用也行)。 * * 存:网易云 cookie(含 MUSIC_U)、QQ 的 musicid + musickey(qm_keyst)。凭证是每用户的敏感数据, - * 请存到私有目录、不要随配置同步分发。 + * 请存到私有目录、不要随配置同步分发。文件以原子替换方式写入,POSIX 系统上权限为 0600。 * * 与原实现不同:这里是**普通类而非全局单例**,且**文件路径由构造方注入**,从而不再耦合任何 * 具体应用的目录(原来写死 `%APPDATA%/FPSMaster`)。多个宿主/mod 同 JVM 使用也不会互相覆盖。 @@ -35,48 +38,95 @@ class MusicCredentialStore(private val file: Path) { private val gson = GsonBuilder().setPrettyPrinting().create() + // 登录回调(扫码轮询线程)、Cookie 登录(异步池)、登出(渲染线程)可能同时改凭证; + // 读写字段和落盘都在 this 上同步,保证文件里永远是某一次完整的状态。 + @Volatile var neteaseCookie: String = "" private set + @Volatile var qqMusicId: String = "" private set + @Volatile var qqMusicKey: String = "" private set /** 从磁盘加载。建议在启动时调用一次。 */ + @Synchronized fun load() { if (!file.exists() || !file.isRegularFile()) { MusicLog.debug("No music auth file at $file") return } + // 旧版本按 umask 写成了 0644,读的时候顺手收紧 + restrictToOwner(file) runCatching { val json = file.reader().use { gson.fromJson(it, JsonObject::class.java) } ?: return - neteaseCookie = json.get("neteaseCookie")?.takeIf { !it.isJsonNull }?.asString ?: "" - qqMusicId = json.get("qqMusicId")?.takeIf { !it.isJsonNull }?.asString ?: "" - qqMusicKey = json.get("qqMusicKey")?.takeIf { !it.isJsonNull }?.asString ?: "" + neteaseCookie = json.get("neteaseCookie")?.takeIf { it.isJsonPrimitive }?.asString ?: "" + qqMusicId = json.get("qqMusicId")?.takeIf { it.isJsonPrimitive }?.asString ?: "" + qqMusicKey = json.get("qqMusicKey")?.takeIf { it.isJsonPrimitive }?.asString ?: "" MusicLog.debug("Loaded music auth from $file") }.onFailure { MusicLog.error("Failed to load music auth file", it) } } + @Synchronized fun setNetease(cookie: String) { neteaseCookie = cookie; save() } + + @Synchronized fun clearNetease() { neteaseCookie = ""; save() } + @Synchronized fun setQq(musicId: String, musicKey: String) { qqMusicId = musicId; qqMusicKey = musicKey; save() } + + @Synchronized fun clearQq() { qqMusicId = ""; qqMusicKey = ""; save() } + /** + * 先写同目录临时文件(创建时即为 0600),再原子替换正式文件:写到一半崩溃或断电时, + * 旧文件保持完整,不会出现半截 JSON 导致两个平台的登录一起丢失。 + */ private fun save() { runCatching { - file.parent?.let { Files.createDirectories(it) } + val dir = file.toAbsolutePath().parent + Files.createDirectories(dir) val json = JsonObject().apply { addProperty("neteaseCookie", neteaseCookie) addProperty("qqMusicId", qqMusicId) addProperty("qqMusicKey", qqMusicKey) addProperty("lastUpdated", System.currentTimeMillis()) } - file.writer().use { gson.toJson(json, it) } + val tmp = createPrivateTempFile(dir, file.fileName.toString()) + try { + Files.newBufferedWriter(tmp, StandardCharsets.UTF_8).use { gson.toJson(json, it) } + try { + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE) + } catch (e: AtomicMoveNotSupportedException) { + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING) + } + } finally { + Files.deleteIfExists(tmp) + } }.onFailure { MusicLog.error("Failed to save music auth file", it) } } + private fun createPrivateTempFile(dir: Path, name: String): Path = + if (isPosix(dir)) { + Files.createTempFile(dir, "$name.", ".tmp", PosixFilePermissions.asFileAttribute(OWNER_ONLY)) + } else { + // Windows 没有 POSIX 权限;凭证放在 %APPDATA%(按用户隔离的 ACL)下。 + Files.createTempFile(dir, "$name.", ".tmp") + } + + private fun restrictToOwner(path: Path) { + if (!isPosix(path)) return + runCatching { Files.setPosixFilePermissions(path, OWNER_ONLY) } + .onFailure { MusicLog.warn("Cannot restrict permissions of $path: ${it.message}") } + } + + private fun isPosix(path: Path): Boolean = + path.fileSystem.supportedFileAttributeViews().contains("posix") + companion object { + private val OWNER_ONLY = PosixFilePermissions.fromString("rw-------") private const val DEFAULT_FILE_NAME = "music_auth.json" /** 放到指定目录下的 `music_auth.json`(或自定义文件名)。 */ diff --git a/src/test/kotlin/top/fpsmaster/music/NeteaseCodeTest.kt b/src/test/kotlin/top/fpsmaster/music/NeteaseCodeTest.kt new file mode 100644 index 0000000..f2b6dbf --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/music/NeteaseCodeTest.kt @@ -0,0 +1,59 @@ +package top.fpsmaster.music + +import com.google.gson.Gson +import com.google.gson.JsonParser +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class NeteaseCodeTest { + private fun obj(s: String) = JsonParser.parseString(s).asJsonObject + + @Test + fun `code 200 and missing code pass`() { + checkNeteaseCode(obj("""{"code":200,"result":{}}"""), "/x") + checkNeteaseCode(obj("""{"result":{}}"""), "/x") + } + + @Test + fun `301 is not logged in`() { + val e = assertFailsWith { checkNeteaseCode(obj("""{"code":301,"msg":"需要登录"}"""), "/x") } + assertEquals(MusicErrorKind.NOT_LOGGED_IN, e.kind) + assertEquals(301, e.platformCode) + } + + @Test + fun `risk control codes are classified`() { + for (code in listOf(-460, -462, 405)) { + val e = assertFailsWith { checkNeteaseCode(obj("""{"code":$code}"""), "/x") } + assertEquals(MusicErrorKind.RISK_CONTROL, e.kind) + } + } + + @Test + fun `other codes are api errors`() { + val e = assertFailsWith { checkNeteaseCode(obj("""{"code":"404"}"""), "/x") } + assertEquals(MusicErrorKind.API_ERROR, e.kind) + assertEquals(404, e.platformCode) + } + + @Test + fun `html and empty bodies are bad responses not gson exceptions`() { + val gson = Gson() + for (body in listOf("502", "", "[1,2]")) { + val e = assertFailsWith { gson.parseObject(body, "t") } + assertEquals(MusicErrorKind.BAD_RESPONSE, e.kind) + } + } + + @Test + fun `json null members do not throw`() { + val o = obj("""{"a":null,"b":{},"c":"x","d":"12"}""") + assertEquals(null, o.get("a").strOrNull()) + assertEquals(null, o.get("b").strOrNull()) + assertEquals("x", o.get("c").strOrNull()) + assertEquals(12, o.get("d").intOrNull()) + assertEquals(null, o.get("c").intOrNull()) + assertEquals(null, o.get("a").asObjOrNull()) + } +} diff --git a/src/test/kotlin/top/fpsmaster/music/QQMusicLoginTest.kt b/src/test/kotlin/top/fpsmaster/music/QQMusicLoginTest.kt new file mode 100644 index 0000000..2d69a57 --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/music/QQMusicLoginTest.kt @@ -0,0 +1,61 @@ +package top.fpsmaster.music + +import top.fpsmaster.music.http.MusicHttp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class QQMusicLoginTest { + /** 按 URL 回放一次扫码确认后的登录链;[onQQLogin] 在换取凭证的请求里触发(模拟授权进行中用户的操作)。 */ + private class FakeHttp(var onQQLogin: () -> Unit = {}) : MusicHttp() { + private fun resp(status: Int = 200, body: String = "", headers: Map> = emptyMap()) = + Response(status, body, body.toByteArray(), headers) + + override fun get(url: String, query: Map, headers: Map, cookies: Map) = + when { + url.endsWith("/ptqrshow") -> resp(headers = mapOf("Set-Cookie" to listOf("qrsig=sig1; Path=/"))) + url.endsWith("/ptqrlogin") -> + resp(body = "ptuiCB('0','0','https://ssl.ptlogin2.graph.qq.com/check_sig?uin=123&ptsigx=abc','0','ok','nick')") + url.endsWith("/check_sig") -> resp(headers = mapOf("Set-Cookie" to listOf("p_skey=pk; Path=/"))) + else -> error("unexpected GET $url") + } + + override fun postForm(url: String, form: Map, headers: Map, cookies: Map) = + resp(302, headers = mapOf("Location" to listOf("https://y.qq.com/portal/wx_redirect.html?code=CODE1&state=state"))) + + override fun postJson(url: String, json: String, query: Map, headers: Map, cookies: Map): Response { + onQQLogin() + return resp(body = """{"code":0,"req_1":{"code":0,"data":{"musicid":"123","musickey":"KEY"}}}""") + } + } + + @Test + fun `confirmed scan commits credentials`() { + val api = QQMusicApi(FakeHttp()) + val qr = api.createQrCode() + assertEquals(QrLoginState.CONFIRMED, api.checkQrCode(qr)) + assertTrue(api.loggedIn) + assertEquals("123", api.musicid) + } + + @Test + fun `clearLogin during authorize discards credentials`() { + val http = FakeHttp() + val api = QQMusicApi(http) + http.onQQLogin = { api.clearLogin() } + val qr = api.createQrCode() + assertEquals(QrLoginState.EXPIRED, api.checkQrCode(qr)) + assertFalse(api.loggedIn) + } + + @Test + fun `new qr during authorize discards old poll result`() { + val http = FakeHttp() + val api = QQMusicApi(http) + val old = api.createQrCode() + http.onQQLogin = { http.onQQLogin = {}; api.createQrCode() } + assertEquals(QrLoginState.EXPIRED, api.checkQrCode(old)) + assertFalse(api.loggedIn) + } +} diff --git a/src/test/kotlin/top/fpsmaster/music/http/MusicHttpTest.kt b/src/test/kotlin/top/fpsmaster/music/http/MusicHttpTest.kt new file mode 100644 index 0000000..c64110f --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/music/http/MusicHttpTest.kt @@ -0,0 +1,91 @@ +package top.fpsmaster.music.http + +import top.fpsmaster.music.MusicApiException +import top.fpsmaster.music.MusicErrorKind +import java.net.ServerSocket +import java.nio.charset.StandardCharsets +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +class MusicHttpTest { + private val server = ServerSocket(0) + private val url get() = "http://127.0.0.1:${server.localPort}/" + + @AfterTest + fun close() = server.close() + + /** 每个连接:读完请求头后交给 [respond] 写原始 HTTP 响应。 */ + private fun serve(respond: (java.io.OutputStream) -> Unit) = thread(isDaemon = true) { + while (!server.isClosed) { + val socket = runCatching { server.accept() }.getOrNull() ?: break + thread(isDaemon = true) { + socket.use { s -> + val reader = s.getInputStream().bufferedReader() + while (true) { + val line = reader.readLine() ?: break + if (line.isEmpty()) break + } + runCatching { respond(s.getOutputStream()) } + } + } + } + } + + @Test + fun `slow trickling response is cut off by the total deadline`() { + serve { out -> + out.write("HTTP/1.1 200 OK\r\nContent-Length: 1000\r\n\r\n".toByteArray()) + out.flush() + // 每 100ms 吐一个字节:单次 read 永远不会超时,只有总时限能截断 + repeat(1000) { + out.write('x'.code) + out.flush() + Thread.sleep(100) + } + } + val http = MusicHttp(connectTimeoutMs = 2_000, readTimeoutMs = 2_000, totalTimeoutMs = 600) + val started = System.nanoTime() + val e = assertFailsWith { http.get(url) } + val elapsedMs = (System.nanoTime() - started) / 1_000_000 + assertEquals(MusicErrorKind.TIMEOUT, e.kind) + assertTrue(elapsedMs < 3_000, "took $elapsedMs ms") + } + + @Test + fun `oversized body is rejected`() { + serve { out -> + out.write("HTTP/1.1 200 OK\r\nContent-Length: 4096\r\n\r\n".toByteArray()) + out.write(ByteArray(4096) { 'a'.code.toByte() }) + out.flush() + } + val http = MusicHttp(maxBodyBytes = 1024) + val e = assertFailsWith { http.get(url) } + assertEquals(MusicErrorKind.BAD_RESPONSE, e.kind) + } + + @Test + fun `connection refused maps to network error`() { + val port = server.localPort + server.close() + val e = assertFailsWith { MusicHttp().get("http://127.0.0.1:$port/") } + assertEquals(MusicErrorKind.NETWORK, e.kind) + } + + @Test + fun `normal response returns body and headers`() { + serve { out -> + val body = "{\"ok\":true}".toByteArray(StandardCharsets.UTF_8) + out.write("HTTP/1.1 200 OK\r\nContent-Length: ${body.size}\r\nSet-Cookie: a=1; Path=/\r\n\r\n".toByteArray()) + out.write(body) + out.flush() + } + val r = MusicHttp().get(url) + assertEquals(200, r.status) + assertEquals("{\"ok\":true}", r.body) + assertEquals("1", r.setCookies["a"]) + } +} diff --git a/src/test/kotlin/top/fpsmaster/music/lyric/LyricParserTest.kt b/src/test/kotlin/top/fpsmaster/music/lyric/LyricParserTest.kt index e996219..b6a0276 100644 --- a/src/test/kotlin/top/fpsmaster/music/lyric/LyricParserTest.kt +++ b/src/test/kotlin/top/fpsmaster/music/lyric/LyricParserTest.kt @@ -113,4 +113,22 @@ class LyricParserTest { assertTrue(LyricParser.parse("").isEmpty()) assertTrue(LyricParser.parse(" ", yrc = " ").isEmpty()) } + + @Test + fun `yrc keeps words that are or start with an opening parenthesis`() { + val yrc = "[1000,1000](1000,300,0)Hold (1300,100,0)((1400,200,0)ooh(1600,100,0)) (1700,300,0)me" + val line = LyricParser.parse(lrc = null, yrc = yrc).single() + assertEquals("Hold (ooh) me", line.text) + assertEquals(listOf("Hold ", "(", "ooh", ") ", "me"), line.words.map { it.text }) + assertEquals(LyricWord(1300, 100, "("), line.words[1]) + } + + @Test + fun `translation goes to the nearest timestamp within tolerance`() { + // 两条翻译都在 500ms 内:应取差值最小的 3.30,而不是先出现的 2.60 + val lrc = "[00:03.20]Line" + val tlyric = "[00:02.80]远的\n[00:03.30]近的" + val lines = LyricParser.parse(lrc, translated = tlyric) + assertEquals("近的", lines[0].translation) + } } diff --git a/src/test/kotlin/top/fpsmaster/music/store/MusicCredentialStoreTest.kt b/src/test/kotlin/top/fpsmaster/music/store/MusicCredentialStoreTest.kt new file mode 100644 index 0000000..1176ce3 --- /dev/null +++ b/src/test/kotlin/top/fpsmaster/music/store/MusicCredentialStoreTest.kt @@ -0,0 +1,59 @@ +package top.fpsmaster.music.store + +import com.google.gson.JsonParser +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermissions +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MusicCredentialStoreTest { + private val dir = Files.createTempDirectory("cadence-store") + private val file = dir.resolve("music_auth.json") + + @Test + fun `round trips credentials`() { + MusicCredentialStore(file).apply { + setNetease("MUSIC_U=abc") + setQq("123", "Q_KEY") + } + val loaded = MusicCredentialStore(file).apply { load() } + assertEquals("MUSIC_U=abc", loaded.neteaseCookie) + assertEquals("123", loaded.qqMusicId) + assertEquals("Q_KEY", loaded.qqMusicKey) + } + + @Test + fun `file is owner only on posix and no temp files remain`() { + if (!dir.fileSystem.supportedFileAttributeViews().contains("posix")) return + MusicCredentialStore(file).setNetease("MUSIC_U=abc") + assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file))) + assertEquals(listOf("music_auth.json"), dir.toFile().list()!!.toList()) + } + + @Test + fun `load tightens permissions of files written by older versions`() { + if (!dir.fileSystem.supportedFileAttributeViews().contains("posix")) return + Files.write(file, """{"neteaseCookie":"x"}""".toByteArray()) + Files.setPosixFilePermissions(file, PosixFilePermissions.fromString("rw-r--r--")) + MusicCredentialStore(file).load() + assertEquals("rw-------", PosixFilePermissions.toString(Files.getPosixFilePermissions(file))) + } + + @Test + fun `concurrent writers always leave a complete json file`() { + val store = MusicCredentialStore(file) + val threads = (0 until 8).map { t -> + thread { + repeat(50) { i -> + if (t % 2 == 0) store.setNetease("MUSIC_U=$t-$i") else store.setQq("$t", "key-$i") + } + } + } + threads.forEach { it.join() } + val json = JsonParser.parseString(String(Files.readAllBytes(file))).asJsonObject + assertTrue(json.get("neteaseCookie").asString.startsWith("MUSIC_U=")) + assertEquals(store.qqMusicKey, json.get("qqMusicKey").asString) + } +}