7 Commits
Author SHA1 Message Date
zijunchiang 914450bc6e 发布 V1.3.0: 关于界面标注框架来源, 清理旧日志 TLS 证书, 文档脱敏
- 删除 app/src/main/res/raw/mili_log.crt(旧 syslog/TLS 上报遗留, 已无引用)
- 关于界面(弹窗+设置页)补充: 框架来源 杨家三郎/麦动KTV, 内核 由 DSH 重构数据库源
- app/build.gradle: versionCode 6 / versionName 1.3.0
- 文档: 远程日志域名脱敏(仅指向 MiliLog.ENDPOINT), 同步 KTV_BRIDGE_API: 8 与版本号
2026-09-12 17:47:50 +08:00
zijunchiang 2779237408 日志系统更新为 GlogCenter(HTTP): 替换原 syslog/TLS 上报
- MiliLog 重写: POST http://log.kasugano.cn/glc/v1/log/add (application/json 单条)
  字段 system/date/text/servername/serverip/loglevel/traceid/user
  date 格式 yyyy-MM-dd HH:mm:ss.SSS; 有界队列+后台线程+失败重试/退避
- init 接口保持不变, 各调用点(MainActivity/KtvJsBridge/SongOkDownloadManager)无需改动
- 新增 updateDeviceContext(): 注入设备 IP 与标识; 网络变化时自动刷新
- AGENTS.md 记录新日志系统用法与字段/时间格式约束
2026-09-12 17:24:43 +08:00
zijunchiang b3b8e6a12d 删除 WX_resized.jpg(本地与仓库, 无引用) 2026-09-12 01:56:38 +08:00
zijunchiang b37dbec443 曲库同步时效 30 天: 未到期启动不联网, 到期后才同步服务器数据库
- DatabaseBootstrapper: 新增 SYNC_TTL_DAYS=30 与 db_sync_at.txt 时间戳
  (lastSyncAt/isSyncDue/syncRemainingMs/syncStatusText/markSynced)
- initializeDatabase: 本地曲库可用且未到期时跳过服务器同步(无网络请求);
  到期或本地不可用才查版本并在有更新时下载
- 设置页: 曲库行显示「30 天同步: N 天后/已到期」; 更新按钮改为手动强制检查
- download(): 成功后记录同步时间(去掉一次多余的远端版本请求)
2026-09-11 23:28:09 +08:00
zijunchiang 4c792361a4 docs(AGENTS): 记录 Gitea Release 发布流程与 ASCII 附件名限制 2026-09-11 23:15:13 +08:00
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
zijunchiang 21eca10ee2 docs: 同步策略迁移至 Gitea(origin), Gitee 停用并加钩子拦截 2026-09-11 22:57:42 +08:00
14 changed files with 344 additions and 233 deletions
+57 -21
View File
@@ -2,33 +2,62 @@
> 本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。 > 本文件供后续开发(含 AI 助手会话)遵循,优先级高于默认习惯。
## 一、代码同步策略(重要,2026-09-11 起生效 ## 一、代码同步策略(2026-09-11 更新
**自动推送 Gitee:已禁用。** **代码主仓库已迁移至 Giteahttps://gitea.kasugano.cn/zijunchiang/maidong-ktv**
- ✅ 允许:本地 `git add` / `git commit`(保留本地提交历史,便于回滚与对比) | 远端 | 地址 | 状态 |
- ❌ 禁止:**自动执行 `git push`**(包括"完成改动后顺手推送"这类默认动作) |---|---|---|
- ⏸ 仅在用户**明确指示**(例如"推送到 Gitee"/"同步代码")时,才执行推送 | `origin` | `https://gitea.kasugano.cn/zijunchiang/maidong-ktv.git` | ✅ 主仓库,**同步目标** |
| `gitee` | `https://gitee.com/zijunchiang/maidong-ktv.git` | ⛔ 已停用(推送被钩子拦截) |
### 手动启用/推送方式 - ✅ 预期动作:完成改动 → 本地 `git commit``git push origin master`(同步到 Gitea
- ⛔ 禁止:推送到 Gitee`pre-push` 钩子会拦截,避免误推送)
- 凭据:Gitea 访问令牌 `dsh-sync`(可在 Gitea「设置 → 应用 → 访问令牌」随时吊销),
存放于 `~/.git-credentials-gitea`(权限 600),由本仓库级 `credential.helper` 引用;
账号密码不写入仓库配置。
已安装 `pre-push` 钩子做拦截(防止误推送)。启用推送二选一: ### 推送方式
```bash ```bash
# 方式 1:创建启用标记(推荐,推送后建议删除标记恢复拦截) cd /home/zijun/dsh/maidongktv/maidong-ktv
touch .git/ALLOW_GITEE_PUSH git push origin master # origin = Gitea(主仓库)
git push origin master git push --no-verify gitee master # 仅在确需回推 Gitee 时使用
rm -f .git/ALLOW_GITEE_PUSH
# 方式 2:临时绕过钩子
git push --no-verify origin master
``` ```
### 远端信息 ### 运行时端点(已全部迁移到 Gitea
- 仓库:https://gitee.com/zijunchiang/maidong-ktv (公开) App 内部两处端点已指向 Gitea(随本版 APK 生效):
- 分支:`master`
- 发布:Gitee Releasetag 形如 `v1.2.5`,需附 `.apk` 附件,供 App 内检查更新) - `AppUpdateManager.kt``gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest`App 内检查更新)
- `KtvJsBridge.kt``gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js`JS 热更新)
> 注意:Gitea 的 raw 规范路径是 `/raw/branch/<分支>/<文件>``/raw/<分支>/<文件>` 会 303 跳转。
> Gitea 的 raw 与 release 下载对**无 User-Agent** 的请求同样放行(App 裸 socket 可直连)。
### 发布新版本(Gitea Release
```bash
TOKEN=$(sed -E 's|https://zijunchiang:([^@]+)@.*|\1|' ~/.git-credentials-gitea)
# 1) 创建 Releasetag 需高于当前 versionName 才会提示更新)
curl -X POST -u "zijunchiang:$TOKEN" -H "Content-Type: application/json" \
-d '{"tag_name":"v1.2.6","name":"麦动KTV V1.2.6","body":"更新说明","target_commitish":"master"}' \
https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases
# 2) 上传 APK 附件(用返回的 release id;附件名必须 ASCII
curl -X POST -u "zijunchiang:$TOKEN" \
-F "attachment=@麦动KTV-V1.2.6-release.apk" \
"https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/<id>/assets?name=maidong-ktv-V1.2.6-release.apk"
```
> ⚠️ **附件文件名必须为 ASCII**Gitea 1.27 的规范下载路径
> `/releases/download/<tag>/<文件名>` 对非 ASCII 文件名返回 404`/attachments/<uuid>` 直链虽可用,
> 但 `releases/latest` API 返回的是规范路径)。因此 Gitea 上的附件用
> `maidong-ktv-V1.2.x-release.apk` 这类 ASCII 名;本地留存的发布包仍可命名
> `麦动KTV-V1.2.x-release.apk`。
>
> 上传 22MB APK 约需 70s,注意命令超时设置。
## 二、构建与产物 ## 二、构建与产物
@@ -59,13 +88,20 @@ $BT/apksigner verify 目标名.apk
### 版本号 ### 版本号
- `app/build.gradle``versionName "1.2.5"` / `versionCode 5` - `app/build.gradle``versionName "1.3.0"` / `versionCode 6`
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
## 三、关键实现约束(避免踩坑) ## 三、关键实现约束(避免踩坑)
- **歌曲源**`app/src/main/assets/mobile/ktv_api.js``CONFIG.HOSTS`(多源自动排序,失败源沉底) - **歌曲源**`app/src/main/assets/mobile/ktv_api.js``CONFIG.HOSTS`(多源自动排序,失败源沉底)
- **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本 - **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本
- **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖 Gitee 数据库文件 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
- **曲库同步时效**:默认 **30 天**`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
- **麦克风**`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 - **麦克风**`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
- **发布**Gitee 仓库必须保持 **public**,否则 App 内检查更新/下载会 403 - **远程日志**:接入 GlogCenter 集中式日志(HTTP 接口,单条 JSON)。**域名属敏感信息,不得写入任何文档**
(端点常量见代码 `MiliLog.ENDPOINT`,需要时查代码,勿在 README/AGENTS 中记录域名)。字段:
`system`/`date`/`text`/`servername`/`serverip`/`loglevel`/`traceid`/`user``date` 必须为
`yyyy-MM-dd HH:mm:ss.SSS`,否则按时间检索异常。客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),
调用点 `MiliLog.info/notice/warn/error` 保持不变;设备 IP/标识通过 `MiliLog.updateDeviceContext()` 注入,
网络变化时自动刷新
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403
+3 -1
View File
@@ -59,6 +59,8 @@
## Project Notice ## Project Notice
> **This repository**: [https://gitea.kasugano.cn/zijunchiang/maidong-ktv](https://gitea.kasugano.cn/zijunchiang/maidong-ktv)
>
> **UI/feature framework credit**: [https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv) > **UI/feature framework credit**: [https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv)
> >
> This repository reworks the upstream "Maidong KTV" UI and feature framework, and the core/data layer was > This repository reworks the upstream "Maidong KTV" UI and feature framework, and the core/data layer was
@@ -68,7 +70,7 @@
Highlights of the reworked data layer: Highlights of the reworked data layer:
- **Library**: synced exclusively from the official Muse service - **Library**: synced exclusively from the official Muse service
(`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`); Gitee hosts code only, no database files. (`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`); the code host carries source only, no database files.
- **Song source**: `w.w345.my` (`i.php` token → `do.php` playback URL) with fixed device identity and - **Song source**: `w.w345.my` (`i.php` token → `do.php` playback URL) with fixed device identity and
reproduced signing; ad/mismatched resources (e.g. `wb66.cn/.../719.ts`) are blocked and retried. reproduced signing; ad/mismatched resources (e.g. `wb66.cn/.../719.ts`) are blocked and retried.
- **JS hot-update**: `app/src/main/assets/mobile/ktv_api.js`, requires the `KTV_BRIDGE_API: 7` marker. - **JS hot-update**: `app/src/main/assets/mobile/ktv_api.js`, requires the `KTV_BRIDGE_API: 7` marker.
+7 -5
View File
@@ -10,6 +10,8 @@
## 📌 项目说明 ## 📌 项目说明
> **本仓库**[https://gitea.kasugano.cn/zijunchiang/maidong-ktv](https://gitea.kasugano.cn/zijunchiang/maidong-ktv)
>
> **内容框架来源**[https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv) > **内容框架来源**[https://gitee.com/yangyachao-X/maidong-ktv](https://gitee.com/yangyachao-X/maidong-ktv)
> >
> 本仓库基于上游「麦动 KTV」的界面与功能框架进行改造,并使用 **DSHDeepSeek Harness** 重写内核与数据源:重新实现曲库同步、歌曲源接入、设备认证、风控应对、远程日志与广告屏蔽等底层逻辑。上游框架的版权归原作者所有,本项目仅作学习与技术交流。 > 本仓库基于上游「麦动 KTV」的界面与功能框架进行改造,并使用 **DSHDeepSeek Harness** 重写内核与数据源:重新实现曲库同步、歌曲源接入、设备认证、风控应对、远程日志与广告屏蔽等底层逻辑。上游框架的版权归原作者所有,本项目仅作学习与技术交流。
@@ -46,11 +48,11 @@
## 内核与数据源(DSH 重写部分) ## 内核与数据源(DSH 重写部分)
- **曲库**:统一走**官方 Muse 同步**`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖 Gitee 承载数据库;Gitee 仓库仅存代码。 - **曲库**:统一走**官方 Muse 同步**`mconn.cherryonline.cn` `mls-api/v1/sqlite/sync`,RSA 登录 + 增量文件校验),不再依赖代码托管仓库承载数据库
- **歌曲源**:接入 `w.w345.my``i.php` 取 token → `do.php` 解析播放地址),固定设备标识并复刻签名算法;广告/错号资源(如返回 `wb66.cn/.../719.ts` 或文件名与 musicno 不一致)会被拦截并重试。 - **歌曲源**:接入 `w.w345.my``i.php` 取 token → `do.php` 解析播放地址),固定设备标识并复刻签名算法;广告/错号资源(如返回 `wb66.cn/.../719.ts` 或文件名与 musicno 不一致)会被拦截并重试。
- **风控应对**:源对陌生出口 IP 存在试用窗口(约 10 分钟,403"检测到异常行为"),App 检测到 403 后弹窗提示更换公网 IP;同 token 退避重试,不放大请求量。 - **风控应对**:源对陌生出口 IP 存在试用窗口(约 10 分钟,403"检测到异常行为"),App 检测到 403 后弹窗提示更换公网 IP;同 token 退避重试,不放大请求量。
- **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 7` 标记)。 - **JS 热更新**:歌曲接口逻辑在 `app/src/main/assets/mobile/ktv_api.js`,安装后从本仓库自动拉取兼容版本(须保留 `KTV_BRIDGE_API: 8` 标记)。
- **应用更新**:以 Gitee Release 标签作为版本号(如 `1.2.5`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。 - **应用更新**:以 Gitea Release 标签作为版本号(如 `1.3.0`),Release 需附带一个 `.apk` 附件;仅当标签高于当前 `BuildConfig.VERSION_NAME` 时提示下载。
## 🔧 编译教程 ## 🔧 编译教程
@@ -130,7 +132,7 @@ adb install -r app/build/outputs/apk/release/app-release.apk
- **曲库初始化失败**:检查设备网络能否访问 `mconn.cherryonline.cn`;已在设置页提供「重置数据库」。 - **曲库初始化失败**:检查设备网络能否访问 `mconn.cherryonline.cn`;已在设置页提供「重置数据库」。
- **点歌报 403 / 无播放地址**:多为歌曲源对当前公网 IP 的试用窗口到期,重启光猫/路由器更换公网 IP 后恢复。 - **点歌报 403 / 无播放地址**:多为歌曲源对当前公网 IP 的试用窗口到期,重启光猫/路由器更换公网 IP 后恢复。
- **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 7` 标记,否则内置版本优先。 - **热更新脚本不生效**:确认远端 `ktv_api.js` 保留 `KTV_BRIDGE_API: 8` 标记,否则内置版本优先。
- **想改签名/ABI**:编辑 `app/build.gradle``signingConfigs``ndk.abiFilters` 后重新编译。 - **想改签名/ABI**:编辑 `app/build.gradle``signingConfigs``ndk.abiFilters` 后重新编译。
## 项目结构 ## 项目结构
@@ -143,7 +145,7 @@ maidong-ktv/
│ │ ├── MuseDbSync.kt # 官方曲库同步 │ │ ├── MuseDbSync.kt # 官方曲库同步
│ │ ├── MuseDatabase.kt # 曲库访问 │ │ ├── MuseDatabase.kt # 曲库访问
│ │ ├── KtvJsBridge.kt # JS 桥(HTTP/RSA/热更新) │ │ ├── KtvJsBridge.kt # JS 桥(HTTP/RSA/热更新)
│ │ ├── MiliLog.kt # 远程 syslog 日志 │ │ ├── MiliLog.kt # 远程日志上报(GlogCenter)
│ │ └── SongOkDownloadManager.kt # 下载管理 │ │ └── SongOkDownloadManager.kt # 下载管理
│ ├── src/main/assets/mobile/ktv_api.js # 歌曲源逻辑(热更新) │ ├── src/main/assets/mobile/ktv_api.js # 歌曲源逻辑(热更新)
│ └── src/main/res/ # 资源 / 证书 │ └── src/main/res/ # 资源 / 证书
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv" applicationId "com.local.ktv"
minSdk 21 minSdk 21
targetSdk 25 targetSdk 25
versionCode 5 versionCode 6
versionName "1.2.5" versionName "1.3.0"
ndk { ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
+1 -1
View File
@@ -1,7 +1,7 @@
/** /**
* KTV Song API - JS Hot Update Module * KTV Song API - JS Hot Update Module
* ==================================== * ====================================
* 从 Gitee 热更新, App 无需发版即可修复 API 变更 * 从 Gitea 热更新, App 无需发版即可修复 API 变更
* *
* 用法: * 用法:
* const api = new KtvApi({proxy: 'http://127.0.0.1:7897'}) * const api = new KtvApi({proxy: 'http://127.0.0.1:7897'})
@@ -11,10 +11,10 @@ import java.io.FileOutputStream
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
/** Gitee Release based in-app updater. */ /** Gitea Release based in-app updater. */
object AppUpdateManager { object AppUpdateManager {
private const val LATEST_RELEASE_API = private const val LATEST_RELEASE_API =
"https://gitee.com/api/v5/repos/zijunchiang/maidong-ktv/releases/latest" "https://gitea.kasugano.cn/api/v1/repos/zijunchiang/maidong-ktv/releases/latest"
data class Release( data class Release(
val version: String, val version: String,
@@ -54,7 +54,7 @@ object AppUpdateManager {
apkName = apkName, apkName = apkName,
) )
}) })
}, "gitee-update-check").start() }, "gitea-update-check").start()
} }
fun download( fun download(
@@ -105,7 +105,7 @@ object AppUpdateManager {
require(partial.renameTo(target)) { "无法保存安装包" } require(partial.renameTo(target)) { "无法保存安装包" }
target target
}) })
}, "gitee-update-download").start() }, "gitea-update-download").start()
} }
/** /**
@@ -7,14 +7,57 @@ import java.util.Locale
/** /**
* 曲库下载引导(仅官方 Muse 同步源)。 * 曲库下载引导(仅官方 Muse 同步源)。
* *
* 说明:已移除 Gitee 数据库分片/镜像下载路径(database_publish 不再发布到 Gitee * 说明:已移除分片/镜像数据库下载路径(database_publish 不再发布到代码仓库
* Gitee 仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api * 代码仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api
* 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类只做版本文件与可读错误包装。 * 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类负责同步时效、版本文件
* 与可读错误包装。
*
* 同步时效:默认 **30 天**[SYNC_TTL_DAYS])。未到期时启动不联网同步,直接用
* 本地曲库;到期后才向服务器查询版本并在有更新时下载。
*/ */
object DatabaseBootstrapper { object DatabaseBootstrapper {
private const val TAG = "DatabaseBootstrapper" private const val TAG = "DatabaseBootstrapper"
/** 曲库同步时效(天):达到时效后才触发服务器同步。 */
const val SYNC_TTL_DAYS = 30
private const val SYNC_TTL_MS = SYNC_TTL_DAYS * 24L * 60 * 60 * 1000
private const val DAY_MS = 24L * 60 * 60 * 1000
private val versionFile: File get() = File(MuseDatabase.defaultDbFile().parentFile, "db_version.txt") private val versionFile: File get() = File(MuseDatabase.defaultDbFile().parentFile, "db_version.txt")
private val syncStampFile: File get() = File(MuseDatabase.defaultDbFile().parentFile, "db_sync_at.txt")
/** 上次成功同步(或成功校验为最新)的时间戳(毫秒);无记录返回 0。 */
fun lastSyncAt(): Long = runCatching {
syncStampFile.takeIf { it.exists() }?.readText()?.trim()?.toLongOrNull()
}.getOrNull() ?: 0L
/** 记录一次成功同步/校验,作为 30 天时效的起点。 */
fun markSynced(at: Long = System.currentTimeMillis()) {
runCatching { syncStampFile.writeText(at.toString()) }
}
/** 同步是否已到期;无记录(从未同步)视为到期。 */
fun isSyncDue(now: Long = System.currentTimeMillis()): Boolean {
val last = lastSyncAt()
return last <= 0L || now - last >= SYNC_TTL_MS
}
/** 距离下次同步的剩余毫秒;已到期或无记录返回 0。 */
fun syncRemainingMs(now: Long = System.currentTimeMillis()): Long {
val last = lastSyncAt()
if (last <= 0L) return 0L
return (last + SYNC_TTL_MS - now).coerceAtLeast(0L)
}
/** 同步状态文案(用于设置界面展示)。 */
fun syncStatusText(now: Long = System.currentTimeMillis()): String {
val last = lastSyncAt()
if (last <= 0L) return "未同步"
val remaining = syncRemainingMs(now)
if (remaining <= 0L) return "已到期"
val days = (remaining + DAY_MS - 1) / DAY_MS
return "$days 天后同步"
}
/** 远端曲库版本(官方 Muse);失败返回 null。 */ /** 远端曲库版本(官方 Muse);失败返回 null。 */
fun fetchRemoteVersion(): String? = MuseDbSync.fetchVersion() fun fetchRemoteVersion(): String? = MuseDbSync.fetchVersion()
@@ -23,16 +66,12 @@ object DatabaseBootstrapper {
versionFile.takeIf { it.exists() }?.readText()?.trim()?.takeIf { it.isNotEmpty() } versionFile.takeIf { it.exists() }?.readText()?.trim()?.takeIf { it.isNotEmpty() }
}.getOrNull() }.getOrNull()
private fun saveLocalDbVersion(version: String) { /** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */
runCatching { versionFile.writeText(version.trim()) }
}
/** 下载曲库:仅官方 Muse 同步源。 */
fun download(onProgress: (Int) -> Unit): Result<File> { fun download(onProgress: (Int) -> Unit): Result<File> {
return runCatching { return runCatching {
val file = MuseDbSync.download(onProgress) val file = MuseDbSync.download(onProgress)
// MuseDbSync 内部已保存版本;此处幂等兜底 // MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点
MuseDbSync.fetchVersion()?.let(::saveLocalDbVersion) markSynced()
file file
}.mapError(::readableError) }.mapError(::readableError)
} }
@@ -30,15 +30,16 @@ import javax.crypto.Cipher
* 在隐藏 WebView 中运行可热更新的歌曲接口脚本。 * 在隐藏 WebView 中运行可热更新的歌曲接口脚本。
* *
* JS 决定接口地址、参数、签名和响应解析;Android 只提供 HTTP 与 RSA 这两个 * JS 决定接口地址、参数、签名和响应解析;Android 只提供 HTTP 与 RSA 这两个
* WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitee 上的 ktv_api.js。 * WebView 中不可靠的底层能力。这样接口变化时只需更新 Gitea 上的 ktv_api.js。
*/ */
class KtvJsBridge(context: Context) { class KtvJsBridge(context: Context) {
companion object { companion object {
private const val TAG = "KtvJsBridge" private const val TAG = "KtvJsBridge"
private const val JS_FILE = "mobile/ktv_api.js" private const val JS_FILE = "mobile/ktv_api.js"
private const val CACHE_FILE = "ktv_api_cache.js" private const val CACHE_FILE = "ktv_api_cache.js"
// 热更新脚本地址(Gitea 主仓库; raw 规范路径为 /raw/branch/<分支>/<文件>)
private const val REMOTE_JS_URL = private const val REMOTE_JS_URL =
"https://gitee.com/zijunchiang/maidong-ktv/raw/master/app/src/main/assets/mobile/ktv_api.js" "https://gitea.kasugano.cn/zijunchiang/maidong-ktv/raw/branch/master/app/src/main/assets/mobile/ktv_api.js"
private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024 private const val MAX_RESPONSE_BYTES = 2 * 1024 * 1024
private const val JS_REQUEST_TIMEOUT_MS = 180_000 private const val JS_REQUEST_TIMEOUT_MS = 180_000
// 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致, // 当前歌曲源 w.w345.my 使用的设备标识(mac + sn)。与用户抓包示例一致,
@@ -250,7 +251,7 @@ class KtvJsBridge(context: Context) {
cacheFile.writeText(script, Charsets.UTF_8) cacheFile.writeText(script, Charsets.UTF_8)
tempFile.delete() tempFile.delete()
} }
Log.i(TAG, "JS cache updated from Gitee (${script.length} chars)") Log.i(TAG, "JS cache updated from Gitea (${script.length} chars)")
// 启动后刚拉到的新版本直接生效;若已有请求在执行,则留到下次启动。 // 启动后刚拉到的新版本直接生效;若已有请求在执行,则留到下次启动。
mainHandler.post { mainHandler.post {
if (ready && callbacks.isEmpty() && !destroyed) { if (ready && callbacks.isEmpty() && !destroyed) {
+90 -18
View File
@@ -162,6 +162,8 @@ class MainActivity : AppCompatActivity() {
startRemoteServer() startRemoteServer()
updateMobileQrOverlay() updateMobileQrOverlay()
updateFullScreenQrOverlay() updateFullScreenQrOverlay()
// 网络/IP 变化后同步日志上报的 serverip。
refreshLogDeviceContext()
} }
main.postDelayed(this, 5000) main.postDelayed(this, 5000)
} }
@@ -459,8 +461,9 @@ class MainActivity : AppCompatActivity() {
// 初始化 API 客户端 (歌曲下载链接) // 初始化 API 客户端 (歌曲下载链接)
init("abe235a87118f6de", "080027deed4f") init("abe235a87118f6de", "080027deed4f")
playbackEngine = KtvPlaybackEngine(applicationContext) playbackEngine = KtvPlaybackEngine(applicationContext)
// 远程日志系统: 加载打包证书并后台连接 syslog 服务器, 启动即上报设备/网络信息。 // 远程日志系统(GlogCenter): 启动即上报设备/网络信息。
MiliLog.init(applicationContext) MiliLog.init(applicationContext)
refreshLogDeviceContext()
reportDeviceBootInfo() reportDeviceBootInfo()
// 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403, // 歌曲源窗口/风控到期守卫: 源对陌生出口 IP 可能在短暂窗口后返回 403,
// 换公网 IP 即恢复。到期时弹一次提示引导用户重启光猫/路由器(重拨换 IP)。 // 换公网 IP 即恢复。到期时弹一次提示引导用户重启光猫/路由器(重拨换 IP)。
@@ -586,23 +589,37 @@ class MainActivity : AppCompatActivity() {
databaseBootstrapRunning = true databaseBootstrapRunning = true
showDatabaseLoading(true, "正在初始化曲库...", null) showDatabaseLoading(true, "正在初始化曲库...", null)
io.execute { io.execute {
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
val updateRequired = remoteVersion != null && remoteVersion != localVersion
var bootstrapError: Throwable? = null var bootstrapError: Throwable? = null
var usedOldDatabase = false var usedOldDatabase = false
var ok = if (updateRequired) false else library.muse.open() // 先尝试打开本地曲库;仅当本地不可用或已过 30 天时效时才联网同步。
if (!ok) { var ok = library.muse.open()
library.muse.close() val syncDue = DatabaseBootstrapper.isSyncDue()
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress) if (!ok || syncDue) {
bootstrapError = result.exceptionOrNull() val reason = if (!ok) "本地曲库不可用" else "已到 ${DatabaseBootstrapper.SYNC_TTL_DAYS} 天同步时效"
ok = result.isSuccess && library.muse.open() Log.i(TAG, "触发曲库服务器同步:$reason")
if (!ok) { val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
// A failed online update must not make an existing local catalog unusable. if (remoteVersion != null) {
usedOldDatabase = library.muse.open() val localVersion = DatabaseBootstrapper.getLocalDbVersion()
ok = usedOldDatabase if (!ok || remoteVersion != localVersion) {
library.muse.close()
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress)
bootstrapError = result.exceptionOrNull()
ok = result.isSuccess && library.muse.open()
if (!ok) {
// A failed online update must not make an existing local catalog unusable.
usedOldDatabase = library.muse.open()
ok = usedOldDatabase
}
} else {
// 版本一致:本次已完成有效校验,重置时效起点。
DatabaseBootstrapper.markSynced()
}
} else {
Log.w(TAG, "曲库同步到期,但获取远端版本失败,本次继续使用本地曲库")
} }
} else {
Log.i(TAG, "曲库未到期(${DatabaseBootstrapper.syncStatusText()}),跳过服务器同步")
} }
val count = if (ok) library.muse.songCount() else 0 val count = if (ok) library.muse.songCount() else 0
@@ -1912,6 +1929,8 @@ class MainActivity : AppCompatActivity() {
// 关于 // 关于
addSettingsTitle(content, "关于") addSettingsTitle(content, "关于")
addSettingsItem(content, "应用名称", "麦动", null) addSettingsItem(content, "应用名称", "麦动", null)
addSettingsItem(content, "框架来源", "杨家三郎/麦动KTV", null)
addSettingsItem(content, "内核", "由 DSH 重构数据库源", null)
addSettingsItem(content, "版本号", BuildConfig.VERSION_NAME, null) addSettingsItem(content, "版本号", BuildConfig.VERSION_NAME, null)
addSettingsItem( addSettingsItem(
content, content,
@@ -3488,6 +3507,11 @@ class MainActivity : AppCompatActivity() {
}, "ktv-heartbeat").start() }, "ktv-heartbeat").start()
} }
/** 远程日志: 把设备 IP 与设备标识同步给日志客户端(GlogCenter 的 serverip/user/traceid)。 */
private fun refreshLogDeviceContext() {
MiliLog.updateDeviceContext(ip = localIp(), deviceId = LEGACY_DEVICE_ID)
}
/** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */ /** 远程日志: App 启动时上报设备信息与网络信息(异步, 尽力而为)。 */
private fun reportDeviceBootInfo() { private fun reportDeviceBootInfo() {
Thread({ Thread({
@@ -8232,7 +8256,7 @@ class MainActivity : AppCompatActivity() {
SettingsEntry( SettingsEntry(
R.drawable.ott_ic_data_upgrade, R.drawable.ott_ic_data_upgrade,
"应用更新", "应用更新",
"当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitee Release", "当前版本 ${BuildConfig.VERSION_NAME},更新来源:Gitea Release",
"检查更新", "检查更新",
) { checkAppUpdate(true) }, ) { checkAppUpdate(true) },
SettingsEntry(R.drawable.ott_ic_about_us, "关于", actionText = "查看") { showAboutDialog() }, SettingsEntry(R.drawable.ott_ic_about_us, "关于", actionText = "查看") { showAboutDialog() },
@@ -8274,7 +8298,8 @@ class MainActivity : AppCompatActivity() {
"统计中…", "统计中…",
"更新", "更新",
) { ) {
io.execute { library.muse.close(); library.muse.open(); main.post { showSettingsSection(1, false) } } // 手动强制检查更新(绕过 30 天时效; 仅当服务器版本更新时才下载)
forceDatabaseSync()
}, },
SettingsEntry(R.drawable.ott_ic_data_setting, "U盘加歌", "扫描U盘内按规定命名的歌曲文件,并添加到曲库中", "立即加歌") { toast("未检测到U盘") }, SettingsEntry(R.drawable.ott_ic_data_setting, "U盘加歌", "扫描U盘内按规定命名的歌曲文件,并添加到曲库中", "立即加歌") { toast("未检测到U盘") },
SettingsEntry(R.drawable.ott_ic_setting_storage_space, "预留存储空间", "当前 ${String.format(Locale.ROOT, "%.1f", reserveStorageGb)} GB") { showReserveStorageDialog() }, SettingsEntry(R.drawable.ott_ic_setting_storage_space, "预留存储空间", "当前 ${String.format(Locale.ROOT, "%.1f", reserveStorageGb)} GB") { showReserveStorageDialog() },
@@ -8315,9 +8340,55 @@ class MainActivity : AppCompatActivity() {
io.execute { io.execute {
val count = runCatching { library.muse.songCount() }.getOrDefault(0) val count = runCatching { library.muse.songCount() }.getOrDefault(0)
val downloaded = runCatching { countDownloadedFiles() }.getOrDefault(0) val downloaded = runCatching { countDownloadedFiles() }.getOrDefault(0)
// 同步时效(30 天): 未到期不联网, 到期后才触发服务器同步。
val syncText = DatabaseBootstrapper.syncStatusText()
main.post { main.post {
if (activeSettingsAdapter === adapter) { if (activeSettingsAdapter === adapter) {
adapter.updateSubtitle("曲库", "数据库 $count 首 本地已下载 $downloaded") adapter.updateSubtitle(
"曲库",
"数据库 $count 首 本地已下载 $downloaded 首 · ${DatabaseBootstrapper.SYNC_TTL_DAYS} 天同步: $syncText",
)
}
}
}
}
/**
* 手动强制检查曲库更新(绕过 30 天时效)
* 仅在服务器版本更新(或本地库不可用)时才下载, 避免无谓的大文件传输
*/
private fun forceDatabaseSync() {
showDatabaseLoading(true, "正在检查曲库更新...", null)
io.execute {
val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion()
if (remoteVersion == null) {
main.post {
showDatabaseLoading(false, "", null)
toast("检查失败:无法连接曲库服务器")
showSettingsSection(1, false)
}
return@execute
}
val localVersion = DatabaseBootstrapper.getLocalDbVersion()
if (library.muse.isAvailable() && remoteVersion == localVersion) {
DatabaseBootstrapper.markSynced()
main.post {
showDatabaseLoading(false, "", null)
toast("曲库已是最新版本")
showSettingsSection(1, false)
}
return@execute
}
library.muse.close()
val result = DatabaseBootstrapper.download(::showDatabaseDownloadProgress)
val ok = result.isSuccess && library.muse.open()
main.post {
if (ok) {
showDatabaseLoading(false, "", null)
toast("曲库同步完成")
showSettingsSection(1, false)
} else {
showDatabaseLoadingFailure("曲库同步失败:${result.exceptionOrNull()?.message.orEmpty()}")
} }
} }
} }
@@ -8855,7 +8926,8 @@ class MainActivity : AppCompatActivity() {
AlertDialog.Builder(this) AlertDialog.Builder(this)
.setTitle("关于麦动") .setTitle("关于麦动")
.setMessage( .setMessage(
"power by 抖音@杨家三郎\n" + "框架来源:杨家三郎/麦动KTV\n" +
"由 DSH 重构数据库源\n" +
"此软件免费开源,请勿付费获取\n" + "此软件免费开源,请勿付费获取\n" +
"版本:" + BuildConfig.VERSION_NAME "版本:" + BuildConfig.VERSION_NAME
) )
+124 -134
View File
@@ -3,199 +3,189 @@ package com.local.ktv
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import java.io.BufferedOutputStream import org.json.JSONObject
import java.security.KeyStore import java.net.HttpURLConnection
import java.security.cert.CertificateFactory import java.net.URL
import java.security.cert.X509Certificate import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.TrustManagerFactory
/** /**
* 远程日志客户端 —— 复刻为内网/私有部署的 syslog 服务器(zb.kasugano.cn:514) * 远程日志客户端 —— GlogCentergotoeasy/glogcenterHTTP 接口
* *
* - 传输: TCP + TLS(信任打包的 raw/mili_log.crt 根证书, 服务器为 Let's Encrypt * 接口:`POST http://log.kasugano.cn/glc/v1/log/add``Content-Type: application/json`
* 签发链 ISRG Root X1, 故该根即可完成链校验; 连接目标域名与证书 SAN 不同, * 单条 JSON 对象(批量用 `/glc/v1/log/addBatch`,此处按单条上报)。
* 属私有部署惯例, 因此只做链校验、不做主机名校验)。
* - 格式: BSD syslog RFC 3164 —— <PRI>MMM dd HH:mm:ss HOSTNAME TAG: message
* - 策略: 单后台线程 + 有界队列; 断线自动重连; 尽力而为, 失败不阻塞业务。
* *
* 日志事件: * 字段(均为字符串,对应 GlogCenter 页面列):
* 1) App 启动时上报设备/网络信息; * system 系统名(本 App 固定 [SYSTEM]
* 2) 点歌解析歌曲源(w.w345.my / 实际下载域名)时实时上报; * date 日期时间,格式 `yyyy-MM-dd HH:mm:ss.SSS`(务必与检索格式一致)
* 3) 网络错误/接口 403/下载失败/播放失败实时上报。 * text 日志内容
* servername 主机名(设备型号)
* serverip 主机 IP(设备局域网 IP)
* loglevel 日志级别(INFO / WARN / ERROR
* traceid 追踪码(此处用设备标识)
* user 用户(此处用设备标识)
*
* 策略:单后台线程 + 有界队列;发送失败重试一次,连续失败退避;尽力而为,不阻塞业务。
* 日志事件:启动设备/网络信息、点歌命中的源与结果、接口 403/网络错误、下载/播放失败。
*/ */
object MiliLog { object MiliLog {
private const val TAG = "MiliLog" private const val TAG = "MiliLog"
private const val HOST = "zb.kasugano.cn" private const val ENDPOINT = "http://log.kasugano.cn/glc/v1/log/add"
private const val PORT = 514
private const val TAG_NAME = "MaidongKTV" /** GlogCenter 页面上的「系统名」,用于区分应用。 */
private const val FACILITY = 16 // local0 private const val SYSTEM = "maidongktv"
private const val MAX_MSG_BYTES = 900 private const val MAX_TEXT_LEN = 900
private const val MAX_QUEUE = 300 private const val MAX_QUEUE = 300
private const val CONNECT_TIMEOUT_MS = 8000 private const val CONNECT_TIMEOUT_MS = 8000
private const val SOCKET_TIMEOUT_MS = 10000 private const val READ_TIMEOUT_MS = 8000
private const val RETRY_SLEEP_MS = 10_000L private const val RETRY_SLEEP_MS = 1500L
private const val FAILURE_BACKOFF_MS = 10_000L
// RFC 3164 severity
private const val SEV_ERROR = 3
private const val SEV_WARNING = 4
private const val SEV_NOTICE = 5
private const val SEV_INFO = 6
private val started = AtomicBoolean(false) private val started = AtomicBoolean(false)
private val running = AtomicBoolean(false) private val running = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<String>(MAX_QUEUE) private val queue = LinkedBlockingQueue<String>(MAX_QUEUE)
private var sslContext: SSLContext? = null
private var hostname = "android"
@Volatile private var socket: SSLSocket? = null
private var senderThread: Thread? = null private var senderThread: Thread? = null
/** 用 app context 初始化(加载打包证书、读取设备标识)。可重复调用。 */ @Volatile private var serverName: String = "android"
@Volatile private var serverIp: String = ""
@Volatile private var traceId: String = ""
@Volatile private var user: String = ""
/** 用 app context 初始化并启动发送线程。可重复调用。 */
fun init(context: Context) { fun init(context: Context) {
if (started.getAndSet(true)) return if (started.getAndSet(true)) return
val appContext = context.applicationContext serverName = sanitize(Build.MODEL).ifEmpty { "android" }
hostname = sanitize(Build.MODEL).ifEmpty { "android" }
sslContext = buildSslContext(appContext)
running.set(true) running.set(true)
senderThread = Thread({ sendLoop() }, "mili-syslog").also { it.isDaemon = true; it.start() } senderThread = Thread({ sendLoop() }, "mili-glog").also { it.isDaemon = true; it.start() }
} }
private fun sanitize(s: String): String = /** 更新上报用的设备网络/身份字段(IP 变化或重新登录时调用)。 */
s.map { if (it.isLetterOrDigit() || it == '-' || it == '_') it else ' ' } fun updateDeviceContext(ip: String? = null, deviceId: String? = null) {
.joinToString("").trim().replace(Regex("\\s+"), "-") if (!ip.isNullOrBlank()) serverIp = ip
if (!deviceId.isNullOrBlank()) {
private fun buildSslContext(context: Context): SSLContext? = try { user = deviceId
val cf = CertificateFactory.getInstance("X.509") traceId = deviceId
val cert = context.resources.openRawResource(R.raw.mili_log).use {
cf.generateCertificate(it) as X509Certificate
} }
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null)
keyStore.setCertificateEntry("mili_log", cert)
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
tmf.init(keyStore)
SSLContext.getInstance("TLS").apply { init(null, tmf.trustManagers, null) }
} catch (e: Throwable) {
Log.e(TAG, "buildSslContext failed", e)
null
} }
// ─── 对外上报接口 ──────────────────────────────── fun info(msg: String) = enqueue("INFO", msg)
fun notice(msg: String) = enqueue("INFO", msg)
fun warn(msg: String) = enqueue("WARN", msg)
fun error(msg: String) = enqueue("ERROR", msg)
fun info(msg: String) = enqueue(SEV_INFO, msg) private fun enqueue(level: String, msg: String) {
fun notice(msg: String) = enqueue(SEV_NOTICE, msg) if (!started.get()) return
fun warn(msg: String) = enqueue(SEV_WARNING, msg) val body = buildBody(level, msg) ?: return
fun error(msg: String) = enqueue(SEV_ERROR, msg) // 队列满时丢最旧,保证新日志能进队。
if (!queue.offer(body)) {
private fun enqueue(severity: Int, msg: String) {
if (!started.get() || sslContext == null) return
val line = format(severity, msg)
if (line == null) return
// 队列满时丢最旧, 保证新日志能进队。
if (!queue.offer(line)) {
queue.poll() queue.poll()
queue.offer(line) queue.offer(body)
} }
} }
private fun format(severity: Int, msg: String): String? { private fun buildBody(level: String, msg: String): String? {
val body = msg.replace(Regex("[\\r\\n]+"), " ").trim() val text = msg.replace(Regex("[\\r\\n]+"), " ").trim()
if (body.isEmpty()) return null if (text.isEmpty()) return null
val truncated = if (body.toByteArray(Charsets.UTF_8).size > MAX_MSG_BYTES) { val truncated = if (text.toByteArray(Charsets.UTF_8).size > MAX_TEXT_LEN) {
body.take(MAX_MSG_BYTES) text.take(MAX_TEXT_LEN)
} else body } else {
val now = Calendar.getInstance() text
val months = arrayOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", }
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec") return runCatching {
val day = String.format(Locale.US, "%2d", now.get(Calendar.DAY_OF_MONTH)) JSONObject().apply {
val time = String.format(Locale.US, "%02d:%02d:%02d", put("system", SYSTEM)
now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE), now.get(Calendar.SECOND)) put("date", timestamp())
val pri = FACILITY * 8 + severity put("text", truncated)
// RFC3164: <PRI>TIMESTAMP HOSTNAME TAG: MSG (TAG 不含空格) put("servername", serverName)
return "<$pri>${months[now.get(Calendar.MONTH)]} $day $time $hostname $TAG_NAME: $truncated" put("serverip", serverIp)
put("loglevel", level)
put("traceid", traceId)
put("user", user)
}.toString()
}.getOrNull()
} }
/** GlogCenter 检索要求的时间格式:yyyy-MM-dd HH:mm:ss.SSS */
private fun timestamp(): String =
SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.ROOT).format(Date())
private fun sanitize(value: String): String =
value.map { if (it.isLetterOrDigit() || it == '-' || it == '_' || it == ' ') it else ' ' }
.joinToString("").trim()
// ─── 发送线程 ──────────────────────────────────── // ─── 发送线程 ────────────────────────────────────
private fun sendLoop() { private fun sendLoop() {
var consecutiveFailures = 0 var consecutiveFailures = 0
while (running.get()) { while (running.get()) {
val line: String = try { val body: String = try {
queue.take() queue.take()
} catch (e: InterruptedException) { } catch (e: InterruptedException) {
break break
} }
var sent = false var sent = post(body)
var attempt = 0
while (!sent && attempt < 2) {
attempt++
try {
val s = connectOrReuse()
val out = BufferedOutputStream(s.getOutputStream())
out.write(line.toByteArray(Charsets.UTF_8))
out.write('\n'.code)
out.flush()
sent = true
consecutiveFailures = 0
// 同一连接顺手把队列里已积压的消息发完。
var next: String? = queue.poll()
while (next != null) {
out.write(next.toByteArray(Charsets.UTF_8))
out.write('\n'.code)
out.flush()
next = queue.poll()
}
} catch (e: Throwable) {
closeSocket()
if (attempt == 1) {
Log.w(TAG, "syslog send failed (${e.message}), retrying...")
try { Thread.sleep(1500) } catch (ie: InterruptedException) { break }
}
}
}
if (!sent) { if (!sent) {
sleepQuietly(RETRY_SLEEP_MS)
sent = post(body)
}
if (sent) {
consecutiveFailures = 0
// 顺手把已积压的日志发完。
var next: String? = queue.poll()
while (next != null) {
post(next)
next = queue.poll()
}
} else {
consecutiveFailures++ consecutiveFailures++
Log.w(TAG, "syslog send dropped: $line") Log.w(TAG, "glog send dropped (failures=$consecutiveFailures): ${body.take(120)}")
if (consecutiveFailures >= 3) { if (consecutiveFailures >= 3) {
consecutiveFailures = 0 consecutiveFailures = 0
try { Thread.sleep(RETRY_SLEEP_MS) } catch (ie: InterruptedException) { break } sleepQuietly(FAILURE_BACKOFF_MS)
} }
} }
} }
closeSocket()
} }
private fun connectOrReuse(): SSLSocket { private fun post(body: String): Boolean = try {
val existing = socket val connection = (URL(ENDPOINT).openConnection() as HttpURLConnection).apply {
if (existing != null && existing.isConnected && !existing.isClosed && !existing.isOutputShutdown) { requestMethod = "POST"
return existing doOutput = true
connectTimeout = CONNECT_TIMEOUT_MS
readTimeout = READ_TIMEOUT_MS
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setRequestProperty("User-Agent", "MaidongKTV/${BuildConfig.VERSION_NAME}")
} }
val context = sslContext ?: throw IllegalStateException("SSL context not ready") try {
val raw = context.socketFactory.createSocket() as SSLSocket connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
raw.connect(java.net.InetSocketAddress(HOST, PORT), CONNECT_TIMEOUT_MS) val code = connection.responseCode
raw.soTimeout = SOCKET_TIMEOUT_MS // 读取并丢弃响应体,确保请求完成(也便于排查)。
raw.startHandshake() val stream = if (code in 200..299) connection.inputStream else connection.errorStream
socket = raw runCatching { stream?.use { it.readBytes() } }
Log.i(TAG, "syslog connected to $HOST:$PORT") code in 200..299
return raw } finally {
connection.disconnect()
}
} catch (e: Throwable) {
Log.w(TAG, "glog send error: ${e.message}")
false
} }
private fun closeSocket() { private fun sleepQuietly(ms: Long) {
try { socket?.close() } catch (ignored: Exception) {} try {
socket = null Thread.sleep(ms)
} catch (ignored: InterruptedException) {
Thread.currentThread().interrupt()
}
} }
/** 供测试/关闭时调用。 */ /** 停止发送(退出时调用。 */
fun shutdown() { fun shutdown() {
running.set(false) running.set(false)
senderThread?.interrupt() senderThread?.interrupt()
senderThread = null senderThread = null
closeSocket()
} }
} }
@@ -20,7 +20,7 @@ import javax.crypto.Cipher
/** /**
* 官方曲库同步:login → cookie → sqlite/sync → 下载 gz → 解压安装。 * 官方曲库同步:login → cookie → sqlite/sync → 下载 gz → 解压安装。
* *
* 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的 Gitee 分片方案。 * 这是原版「时光KTV / 麦动」的曲库分发方式,取代此前临时使用的分片分发方案。
* 授权门控发生在服务端(未授权设备 sync 返回空 files),本地只负责完整复刻请求协议。 * 授权门控发生在服务端(未授权设备 sync 返回空 files),本地只负责完整复刻请求协议。
*/ */
object MuseDbSync { object MuseDbSync {
@@ -10,8 +10,8 @@ import java.util.concurrent.atomic.AtomicReference
/** /**
* 歌曲 API 客户端 —— JS 热更新桥接 * 歌曲 API 客户端 —— JS 热更新桥接
* ================================= * =================================
* - 通过 KtvJsBridge 调用 Gitee 上的 JS 脚本 * - 通过 KtvJsBridge 调用 Gitea 上的 JS 脚本
* - API 变更时只需更新 Gitee JS 文件,App 无需发版 * - API 变更时只需更新 Gitea JS 文件,App 无需发版
* - 首次播放时实时获取 URL,不存在过期问题 * - 首次播放时实时获取 URL,不存在过期问题
*/ */
object SongApiClient { object SongApiClient {
-31
View File
@@ -1,31 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4
WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu
ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY
MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc
h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+
0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U
A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH
B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC
B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv
KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn
OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn
jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw
qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI
rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV
HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL
ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ
3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK
NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5
ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur
TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC
jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc
oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq
4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d
emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
-----END CERTIFICATE-----