Files
maidong-ktv/app/src/main/java/com/local/ktv/MuseDbSync.kt
T
zijunchiang ba31caaa38 全量迁移到 Gitea: App更新检查/JS热更新端点上链 Gitea, 文档同步
- AppUpdateManager: releases/latest 改用 gitea.kasugano.cn API
- KtvJsBridge: JS 热更新改用 Gitea raw(/raw/branch/master/...)
- MainActivity/SongApiClient/DatabaseBootstrapper/MuseDbSync: 文案与注释更新
- README.md/README.en.md: 标注本仓库地址(Gitea), 保留上游框架归属链接
- AGENTS.md: 运行时端点迁移状态
2026-09-11 23:08:46 +08:00

237 lines
10 KiB
Kotlin

package com.local.ktv
import android.util.Base64
import android.util.Log
import org.json.JSONObject
import java.io.BufferedOutputStream
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL
import java.security.KeyFactory
import java.security.MessageDigest
import java.security.spec.X509EncodedKeySpec
import java.util.Locale
import java.util.zip.GZIPInputStream
import javax.crypto.Cipher
/**
* 官方曲库同步:login → cookie → sqlite/sync → 下载 gz → 解压安装。
*
* 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的分片分发方案。
* 授权门控发生在服务端(未授权设备 sync 返回空 files),本地只负责完整复刻请求协议。
*/
object MuseDbSync {
private const val TAG = "MuseDbSync"
private const val LOGIN_URL = "https://mconn.cherryonline.cn/mls-api/v1/login"
private const val SYNC_URL = "https://mconn.cherryonline.cn/mls-api/v1/sqlite/sync"
private const val RSA_PUBLIC_KEY =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAucL0oFErd7REM6TSNa3EZdN1YaOs4J1eCybLPyoQ9ru3q1HU67agC9FzhrCG/RvAQUya5iPmQ8Caed05vqcCVcyJChmkSOGQ7DVShe2rGuTMNlpoRV6UzfcraaVS++7m2K/+kSZJ8OAhhhVuqPruMjsFYpdtstAwvyZT28b+eENwzpp9UHqsooZc7FZ0H8kTbs6XMkw4nIWo+4HoPAhNLEY+xdHvwY6drF/3WDTvsaoMrs73TVQCEEHzZNIz2H/is9VLMnIyOfnfcJi9br78Fj2xHzxu3sAySBOTVLmUMxqYh/g1ox5OXGcW93HJkQLkBi42tFAEkWYlYyl93+jbbQIDAQAB"
private const val MUSIC_FILE_TYPE = 214
private const val CHANNEL = "common"
private const val MODE = "ott"
private const val IP = "172.16.32.15"
private data class DbFile(val url: String, val md5: String, val version: String)
private val target: File get() = MuseDatabase.defaultDbFile()
// 原版「时光KTV」mls-api 登录使用的设备标识(已验证:用该 id 从任意网络
// 调 sqlite/sync 都能返回文件;随机 id 或 b 前缀的 mad-api box id 均返回空)。
// 注意 muse_open=0 并不阻止下载,真正门槛是 mls 登录的 device_id。
private val deviceId: String = "s00e04b021b2d"
/** 取远端曲库版本;失败返回 null。版本取自 files[0].date(毫秒时间戳)。 */
fun fetchVersion(): String? = runCatching {
val cookie = login() ?: return null
val file = sync(cookie) ?: return null
file.version
}.getOrNull()
/** 下载并安装曲库。失败抛异常,由调用方决定是否回退到其它源。 */
fun download(onProgress: (Int) -> Unit): File {
val cookie = login() ?: error("登录曲库服务失败")
val file = sync(cookie) ?: error("曲库同步失败或无可用文件")
val version = file.version
val gzFile = File(target.parentFile, "${target.name}.gz.download")
val tmpDb = File(target.parentFile, "${target.name}.download")
try {
downloadFile(file.url, gzFile) { pct -> onProgress((pct * 80 / 100).coerceIn(0, 80)) }
onProgress(80)
if (file.md5.isNotEmpty()) {
check(md5(gzFile).equals(file.md5, ignoreCase = true)) { "曲库文件校验失败" }
}
onProgress(84)
GZIPInputStream(gzFile.inputStream().buffered(256 * 1024)).use { gz ->
BufferedOutputStream(FileOutputStream(tmpDb)).use { out ->
val buffer = ByteArray(256 * 1024)
while (true) {
val count = gz.read(buffer)
if (count < 0) break
out.write(buffer, 0, count)
}
}
}
onProgress(96)
val backup = File(target.parentFile, "${target.name}.backup")
backup.delete()
if (target.exists()) check(target.renameTo(backup)) { "无法备份旧曲库" }
if (!tmpDb.renameTo(target)) {
backup.renameTo(target)
error("无法安装下载的曲库")
}
backup.delete()
saveVersion(version)
onProgress(100)
return target
} finally {
gzFile.delete()
}
}
private fun saveVersion(version: String) {
runCatching { File(target.parentFile, "db_version.txt").writeText(version) }
}
private fun login(): String? {
val encrypted = rsaEncrypt(RSA_PUBLIC_KEY, """{"device_id":"$deviceId"}""")
val body = JSONObject()
.put("encrypted_data", encrypted)
.put("ip", IP)
.put("dns", "")
.put("router", "")
.put("subnet_mask", "255.255.255.0")
.put("channel", CHANNEL)
.put("mode", MODE)
.toString()
return postForCookie(LOGIN_URL, body)
}
private fun sync(cookie: String): DbFile? {
val body = JSONObject()
.put("last_sync_at", 0)
.put("music_file_type", MUSIC_FILE_TYPE)
.toString()
val resp = postJson(SYNC_URL, body, cookie) ?: return null
val files = resp.optJSONArray("files") ?: return null
if (files.length() == 0) return null
val f = files.getJSONObject(0)
val url = f.optString("url")
if (url.isBlank()) return null
val version = f.optLong("date", 0L).takeIf { it > 0 }?.toString()
?: f.optString("day").takeIf(String::isNotBlank)
?: f.optLong("db_vc", 0L).takeIf { it > 0 }?.toString()
?: "0"
return DbFile(url, f.optString("md5"), version)
}
/** POST 并捕获 Set-Cookie 中的 muse-portal-connector。 */
private fun postForCookie(url: String, jsonBody: String): String? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 15_000
readTimeout = 30_000
doOutput = true
instanceFollowRedirects = true
setRequestProperty("Content-Type", "application/json; charset=UTF-8")
setRequestProperty("Accept", "*/*")
setRequestProperty("User-Agent", "MaidongKTV/1.2")
}
conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) }
if (conn.responseCode !in 200..299) return null
conn.inputStream.use { it.readBytes() }
conn.getHeaderField("Set-Cookie")
?.substringBefore(';')
?.takeIf { it.startsWith("muse-portal-connector=") }
} catch (error: Throwable) {
Log.w(TAG, "login failed: ${error.message}")
null
} finally {
conn?.disconnect()
}
}
private fun postJson(url: String, jsonBody: String, cookie: String): JSONObject? {
var conn: HttpURLConnection? = null
return try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 15_000
readTimeout = 30_000
doOutput = true
instanceFollowRedirects = true
setRequestProperty("Content-Type", "application/json; charset=UTF-8")
setRequestProperty("Accept", "*/*")
setRequestProperty("Cookie", cookie)
setRequestProperty("User-Agent", "MaidongKTV/1.2")
}
conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) }
if (conn.responseCode !in 200..299) return null
val text = conn.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
JSONObject(text)
} catch (error: Throwable) {
Log.w(TAG, "sync failed: ${error.message}")
null
} finally {
conn?.disconnect()
}
}
private fun downloadFile(url: String, dest: File, onProgress: (Int) -> Unit) {
var conn: HttpURLConnection? = null
try {
conn = (URL(url).openConnection() as HttpURLConnection).apply {
connectTimeout = 30_000
readTimeout = 120_000
instanceFollowRedirects = true
setRequestProperty("User-Agent", "MaidongKTV/1.2")
setRequestProperty("Accept", "*/*")
}
val code = conn.responseCode
check(code in 200..299) { "HTTP $code" }
val total = conn.contentLengthLong
conn.inputStream.buffered(256 * 1024).use { input ->
dest.outputStream().buffered(256 * 1024).use { out ->
val buffer = ByteArray(256 * 1024)
var read = 0L
while (true) {
val count = input.read(buffer)
if (count < 0) break
out.write(buffer, 0, count)
read += count
if (total > 0) onProgress(((read * 100) / total).toInt().coerceIn(0, 99))
}
}
}
} finally {
conn?.disconnect()
}
}
private fun rsaEncrypt(publicKeyBase64: String, plaintext: String): String = runCatching {
val keyBytes = Base64.decode(publicKeyBase64.replace("\\s".toRegex(), ""), Base64.DEFAULT)
val publicKey = KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(keyBytes))
val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding")
cipher.init(Cipher.ENCRYPT_MODE, publicKey)
Base64.encodeToString(cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
}.getOrDefault("")
private fun md5(file: File): String {
val digest = MessageDigest.getInstance("MD5")
file.inputStream().buffered(256 * 1024).use { input ->
val buffer = ByteArray(256 * 1024)
while (true) {
val count = input.read(buffer)
if (count < 0) break
digest.update(buffer, 0, count)
}
}
return digest.digest().joinToString("") { "%02X".format(Locale.ROOT, it.toInt() and 0xff) }
}
}