Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ build/
*.log
.idea/
.DS_Store
bin/
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -109,7 +123,7 @@ MusicLog.logger = object : MusicLogger {
}
```

默认无操作,不打印任何东西。
默认无操作,不打印任何东西。凭证、cookie 值、搜索词不会出现在 info 及以上级别的日志里。

## 功能矩阵

Expand All @@ -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` 现场构建并把坐标
Expand All @@ -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**
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ plugins {
}

group = "top.fpsmaster"
version = "0.1.1"
version = "0.1.2"

dependencies {
// 唯一的第三方运行时依赖:Gson(JSON 解析)。其余全部走 JDK 内置。
Expand Down
32 changes: 32 additions & 0 deletions src/main/kotlin/top/fpsmaster/music/JsonExt.kt
Original file line number Diff line number Diff line change
@@ -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 成员访问。
Expand All @@ -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)
}
38 changes: 36 additions & 2 deletions src/main/kotlin/top/fpsmaster/music/MusicModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
106 changes: 76 additions & 30 deletions src/main/kotlin/top/fpsmaster/music/NeteaseMusicApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -23,6 +27,7 @@ class NeteaseMusicApi(
private val gson = Gson()

/** 完整 cookie 串(如登录后持久化到磁盘再回填)。 */
@Volatile
var cookie: String = initialCookie

// ---- 公开 API ----
Expand All @@ -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() } }
}

/** 获取播放直链。 */
Expand All @@ -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)
Expand All @@ -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))
}

Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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<Track> {
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() } }
}

/** 用户歌单列表(需登录)。 */
Expand All @@ -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,
)
}
}
Expand All @@ -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() {
Expand All @@ -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。
Expand Down Expand Up @@ -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<String, String>): 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<String, String>, cookies: Map<String, String>): MusicHttp.Response =
Expand All @@ -231,6 +258,7 @@ class NeteaseMusicApi(
return base
}

@Synchronized
private fun captureCookies(set: Map<String, String>) {
// 至少需要 MUSIC_U;把新 cookie 合并进现有串
val merged = LinkedHashMap<String, String>()
Expand All @@ -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,
Expand Down
Loading
Loading