feat: release QieKan 3.26.15
Auto Release / build (app, release) (push) Canceled after 0s
Auto Release / create_release (push) Canceled after 0s
Auto Release / prepare (push) Canceled after 0s
CodeQL / Analyze (java-kotlin) (java-kotlin) (push) Canceled after 0s
Deploy Docs / build (push) Canceled after 0s
Deploy Docs / Deploy (push) Canceled after 0s

This commit is contained in:
2026-08-08 12:22:26 +08:00
parent 25e22b5711
commit d2d2ecd3bc
58 changed files with 1863 additions and 172 deletions
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.serialization)
}
android {
compileSdk = 37
namespace = "io.legado.audiobookshelf.client"
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
lint {
checkDependencies = true
targetSdk = 37
}
testOptions {
targetSdk = 37
}
}
dependencies {
api(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,170 @@
package io.legado.audiobookshelf.client
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class AudiobookshelfApi(
private val client: OkHttpClient = OkHttpClient.Builder().build()
) {
fun getStatus(baseUrl: String): AudiobookshelfServerStatus {
val request = requestBuilder(baseUrl, "status").get().build()
val dto = execute<StatusDto>(request)
if (dto.app != AUDIOBOOKSHELF_APP) {
throw IllegalArgumentException("目标地址不是 Audiobookshelf 服务")
}
return AudiobookshelfServerStatus(
version = dto.serverVersion,
initialized = dto.isInit,
authMethods = dto.authMethods
)
}
fun login(baseUrl: String, username: String, password: String): AudiobookshelfAuthSession {
val body = json.encodeToString(LoginRequestDto(username, password))
.toRequestBody(JSON_MEDIA_TYPE)
val request = requestBuilder(baseUrl, "login")
.header(RETURN_TOKENS_HEADER, "true")
.post(body)
.build()
return execute<AuthResponseDto>(request).user.toSession()
}
fun refresh(baseUrl: String, refreshToken: String): AudiobookshelfAuthSession {
val request = requestBuilder(baseUrl, "auth/refresh")
.header(REFRESH_TOKEN_HEADER, refreshToken)
.post(EMPTY_BODY)
.build()
return execute<AuthResponseDto>(request).user.toSession()
}
fun getLibraries(baseUrl: String, accessToken: String): List<AudiobookshelfLibrary> {
val request = requestBuilder(baseUrl, "api/libraries")
.header(AUTHORIZATION, "Bearer $accessToken")
.get()
.build()
return execute<LibrariesResponseDto>(request).libraries.map { library ->
AudiobookshelfLibrary(
id = library.id,
name = library.name,
mediaType = library.mediaType,
icon = library.icon
)
}
}
private fun requestBuilder(baseUrl: String, path: String): Request.Builder {
return Request.Builder()
.url("${normalizeBaseUrl(baseUrl)}/${path.trimStart('/')}")
.header(ACCEPT, "application/json")
}
private inline fun <reified T> execute(request: Request): T {
client.newCall(request).execute().use { response ->
val responseBody = response.body.string()
if (!response.isSuccessful) {
throw AudiobookshelfApiException(
statusCode = response.code,
message = extractErrorMessage(responseBody)
?: "Audiobookshelf 请求失败(HTTP ${response.code}"
)
}
return runCatching { json.decodeFromString<T>(responseBody) }
.getOrElse { error ->
throw AudiobookshelfApiException(
statusCode = response.code,
message = "Audiobookshelf 返回了无法识别的数据:${error.message}"
)
}
}
}
private fun extractErrorMessage(body: String): String? {
return runCatching {
val root = json.parseToJsonElement(body).jsonObject
root["error"]?.jsonPrimitive?.contentOrNull
?: root["message"]?.jsonPrimitive?.contentOrNull
}.getOrNull()?.takeIf { it.isNotBlank() }
}
companion object {
private const val AUDIOBOOKSHELF_APP = "audiobookshelf"
private const val ACCEPT = "Accept"
private const val AUTHORIZATION = "Authorization"
private const val RETURN_TOKENS_HEADER = "x-return-tokens"
private const val REFRESH_TOKEN_HEADER = "x-refresh-token"
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val EMPTY_BODY = ByteArray(0).toRequestBody(null)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
fun normalizeBaseUrl(input: String): String {
val value = input.trim().trimEnd('/')
val parsed = value.toHttpUrlOrNull()
?: throw IllegalArgumentException("请输入完整的 HTTP 或 HTTPS 服务器地址")
require(parsed.query == null && parsed.fragment == null) {
"服务器地址不能包含查询参数或片段"
}
return parsed.toString().trimEnd('/')
}
}
}
@Serializable
private data class StatusDto(
val app: String,
val serverVersion: String = "",
val isInit: Boolean = false,
val authMethods: List<String> = emptyList()
)
@Serializable
private data class LoginRequestDto(
val username: String,
val password: String
)
@Serializable
private data class AuthResponseDto(
val user: AuthUserDto
)
@Serializable
private data class AuthUserDto(
val username: String = "",
val accessToken: String,
val refreshToken: String
) {
fun toSession(): AudiobookshelfAuthSession {
return AudiobookshelfAuthSession(
username = username,
accessToken = accessToken,
refreshToken = refreshToken
)
}
}
@Serializable
private data class LibrariesResponseDto(
val libraries: List<LibraryDto> = emptyList()
)
@Serializable
private data class LibraryDto(
val id: String,
val name: String,
val mediaType: String,
@SerialName("icon") val icon: String? = null
)
@@ -0,0 +1,6 @@
package io.legado.audiobookshelf.client
class AudiobookshelfApiException(
val statusCode: Int,
message: String
) : Exception(message)
@@ -0,0 +1,20 @@
package io.legado.audiobookshelf.client
data class AudiobookshelfServerStatus(
val version: String,
val initialized: Boolean,
val authMethods: List<String>
)
data class AudiobookshelfAuthSession(
val username: String,
val accessToken: String,
val refreshToken: String
)
data class AudiobookshelfLibrary(
val id: String,
val name: String,
val mediaType: String,
val icon: String?
)
@@ -0,0 +1,30 @@
package io.legado.audiobookshelf.client
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class AudiobookshelfApiTest {
@Test
fun normalizeBaseUrl_keepsRouterBasePathAndRemovesTrailingSlash() {
assertEquals(
"https://example.com/audiobookshelf",
AudiobookshelfApi.normalizeBaseUrl(" https://example.com/audiobookshelf/ ")
)
}
@Test
fun normalizeBaseUrl_rejectsMissingScheme() {
assertThrows(IllegalArgumentException::class.java) {
AudiobookshelfApi.normalizeBaseUrl("example.com:2048")
}
}
@Test
fun normalizeBaseUrl_rejectsQueryParameters() {
assertThrows(IllegalArgumentException::class.java) {
AudiobookshelfApi.normalizeBaseUrl("https://example.com/?token=secret")
}
}
}