麦动KTV: 移除Gitee数据库(改官方Muse同步), 歌曲源切w.w345.my, 远程日志+广告屏蔽

- DatabaseBootstrapper: 删除 Gitee 分片/manifest/blob 下载逻辑, 曲库只走官方 MuseDbSync
- build.gradle: 移除 GITEE_DATABASE_* 构建参数
- MainActivity: 重置数据库文案改为官方同步
- ktv_api.js: 歌曲源仅 w.w345.my, 缓存标记 7, 广告/错号资源拦截+重试
- 远程日志: 点歌记录 编号/标题/请求源/状态
This commit is contained in:
zijunchiang
2026-09-04 21:23:21 +08:00
commit 0c26f6da68
1287 changed files with 25218 additions and 0 deletions
@@ -0,0 +1,241 @@
package com.local.ktv
import android.content.Context
import java.io.BufferedInputStream
import java.net.Inet4Address
import java.net.InetAddress
import java.net.URI
import java.net.ServerSocket
import java.net.Socket
import java.net.URLDecoder
import java.util.Locale
import java.util.concurrent.Executors
/** Small, dependency-free HTTP server used only by devices on the local LAN. */
class LocalRemoteServer(private val context: Context) {
data class ApiResponse(
val status: Int = 200,
val body: String = "",
val contentType: String = "application/json; charset=utf-8",
val bytes: ByteArray? = null
)
interface Callback {
fun statusJson(): String
fun catalogJson(): String
fun queueJson(): String
fun rankJson(): String
fun filterJson(type: String?, value: String?): String
fun favoritesJson(): String
fun search(query: String?): String
fun order(query: String?): String
fun command(action: String?, params: Map<String, String>): String
fun handle(method: String, path: String, query: Map<String, String>, body: String): ApiResponse =
ApiResponse(404, "{\"ok\":false,\"code\":\"NOT_FOUND\",\"error\":\"接口不存在\"}")
}
private var pool = Executors.newCachedThreadPool()
@Volatile private var running = false
@Volatile private var boundAddress: String? = null
@Volatile private var boundPort = -1
@Volatile private var lastError: String? = null
private var server: ServerSocket? = null
private var callback: Callback? = null
@Synchronized
fun start(address: String?, callback: Callback): Boolean {
if (running && address == boundAddress) return true
stop()
this.callback = callback
if (!isPrivateIpv4(address)) {
lastError = "请先连接 Wi-Fi 或有线局域网"
return false
}
if (pool.isShutdown) pool = Executors.newCachedThreadPool()
var candidate: ServerSocket? = null
for (port in 8765..8775) {
try {
candidate = ServerSocket(port, 32)
boundPort = port
break
} catch (_: Exception) {
}
}
if (candidate == null) {
lastError = "端口 87658775 均被占用"
return false
}
server = candidate
boundAddress = address
lastError = null
running = true
pool.execute {
try {
while (running) {
val socket = server?.accept() ?: break
pool.execute { handle(socket) }
}
} catch (e: Exception) {
if (running) lastError = e.message ?: "本地服务异常"
} finally {
running = false
}
}
return true
}
@Synchronized
fun stop() {
running = false
runCatching { server?.close() }
server = null
boundPort = -1
boundAddress = null
pool.shutdownNow()
}
fun isRunning(): Boolean = running
fun port(): Int = boundPort
fun address(): String? = boundAddress
fun error(): String? = lastError
fun url(): String? = if (running && boundAddress != null && boundPort > 0) {
"http://$boundAddress:$boundPort/mobile"
} else null
private fun handle(socket: Socket) {
runCatching {
socket.soTimeout = 5000
socket.use { client ->
val remote = client.inetAddress
if (!isPrivateAddress(remote)) {
write(client, ApiResponse(403, error("FORBIDDEN", "仅允许局域网设备访问")))
return
}
val input = BufferedInputStream(client.getInputStream())
val requestLine = readLine(input) ?: return
val parts = requestLine.split(' ')
if (parts.size < 2) {
write(client, ApiResponse(400, error("BAD_REQUEST", "请求格式错误")))
return
}
val method = parts[0].uppercase(Locale.US)
val target = parts[1]
val headers = linkedMapOf<String, String>()
while (true) {
val line = readLine(input) ?: break
if (line.isEmpty()) break
val colon = line.indexOf(':')
if (colon > 0) headers[line.substring(0, colon).lowercase(Locale.US)] = line.substring(colon + 1).trim()
}
if (!validHost(headers["host"]) || !validOrigin(headers["origin"])) {
write(client, ApiResponse(403, error("BAD_ORIGIN", "请求来源无效")))
return
}
val length = headers["content-length"]?.toIntOrNull()?.coerceIn(0, 65536) ?: 0
val bodyBytes = ByteArray(length)
var offset = 0
while (offset < length) {
val count = input.read(bodyBytes, offset, length - offset)
if (count < 0) break
offset += count
}
val path = target.substringBefore('?')
val query = parseQuery(target.substringAfter('?', ""))
val body = String(bodyBytes, 0, offset, Charsets.UTF_8)
val response = when {
path == "/" || path == "/mobile" || path == "/mobile/" -> asset("mobile/index.html", "text/html; charset=utf-8")
path == "/mobile/app.css" -> asset("mobile/app.css", "text/css; charset=utf-8")
path == "/mobile/logo.css" -> asset("mobile/logo.css", "text/css; charset=utf-8")
path == "/mobile/app.js" -> asset("mobile/app.js", "application/javascript; charset=utf-8")
path == "/res/drawable-nodpi/ktv_logo.png" -> logoAsset()
path.startsWith("/api/v1/") -> callback?.handle(method, path, query, body)
?: ApiResponse(503, error("NOT_READY", "服务尚未准备完成"))
else -> ApiResponse(404, error("NOT_FOUND", "接口不存在"))
}
write(client, response)
}
}
}
private fun asset(name: String, type: String): ApiResponse = try {
val text = context.assets.open(name).bufferedReader(Charsets.UTF_8).use { it.readText() }
ApiResponse(200, text, type)
} catch (_: Exception) {
ApiResponse(404, error("ASSET_NOT_FOUND", "页面资源缺失"))
}
private fun logoAsset(): ApiResponse = try {
val bytes = context.resources.openRawResource(R.drawable.ktv_logo).use { it.readBytes() }
ApiResponse(200, contentType = "image/png", bytes = bytes)
} catch (_: Exception) {
ApiResponse(404, error("ASSET_NOT_FOUND", "Logo 资源缺失"))
}
private fun validHost(host: String?): Boolean {
if (host.isNullOrBlank()) return false
val expected = boundAddress ?: return false
val name = host.substringBefore(':')
return name == expected || isPrivateIpv4(name)
}
private fun validOrigin(origin: String?): Boolean {
if (origin.isNullOrBlank() || origin == "null") return true
return runCatching {
val uri = URI(origin)
uri.scheme == "http" && uri.port == boundPort && isPrivateIpv4(uri.host)
}.getOrDefault(false)
}
private fun readLine(input: BufferedInputStream): String? {
val bytes = ArrayList<Byte>()
while (bytes.size < 8192) {
val b = input.read()
if (b < 0) return if (bytes.isEmpty()) null else bytes.toByteArray().toString(Charsets.UTF_8)
if (b == '\n'.code) break
if (b != '\r'.code) bytes.add(b.toByte())
}
return bytes.toByteArray().toString(Charsets.UTF_8)
}
private fun parseQuery(query: String): Map<String, String> = buildMap {
if (query.isBlank()) return@buildMap
query.split('&').forEach { part ->
val key = URLDecoder.decode(part.substringBefore('='), "UTF-8")
val value = URLDecoder.decode(part.substringAfter('=', ""), "UTF-8")
put(key, value)
}
}
private fun write(socket: Socket, response: ApiResponse) {
val bytes = response.bytes ?: response.body.toByteArray(Charsets.UTF_8)
val reason = when (response.status) {
200 -> "OK"; 201 -> "Created"; 204 -> "No Content"; 400 -> "Bad Request"
403 -> "Forbidden"; 404 -> "Not Found"; 409 -> "Conflict"; else -> "Error"
}
val output = socket.getOutputStream()
output.write(("HTTP/1.1 ${response.status} $reason\r\n" +
"Content-Type: ${response.contentType}\r\n" +
"Cache-Control: no-store\r\n" +
"X-Content-Type-Options: nosniff\r\n" +
"Content-Security-Policy: default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'\r\n" +
"Content-Length: ${bytes.size}\r\nConnection: close\r\n\r\n").toByteArray())
output.write(bytes)
output.flush()
}
companion object {
fun isPrivateIpv4(value: String?): Boolean = runCatching {
val address = InetAddress.getByName(value)
address is Inet4Address && isPrivateAddress(address)
}.getOrDefault(false)
private fun isPrivateAddress(address: InetAddress): Boolean {
if (address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress) return true
val b = address.address
return b.size == 4 && (b[0].toInt() and 0xff) == 100 && (b[1].toInt() and 0xc0) == 64
}
private fun error(code: String, message: String): String =
"{\"ok\":false,\"code\":\"$code\",\"error\":\"$message\"}"
}
}