From b37dbec4430569bdd253f42e93d184ddcf80f5f8 Mon Sep 17 00:00:00 2001 From: zijunchiang Date: Fri, 11 Sep 2026 23:28:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B2=E5=BA=93=E5=90=8C=E6=AD=A5=E6=97=B6?= =?UTF-8?q?=E6=95=88=2030=20=E5=A4=A9:=20=E6=9C=AA=E5=88=B0=E6=9C=9F?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E4=B8=8D=E8=81=94=E7=BD=91,=20=E5=88=B0?= =?UTF-8?q?=E6=9C=9F=E5=90=8E=E6=89=8D=E5=90=8C=E6=AD=A5=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E6=95=B0=E6=8D=AE=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DatabaseBootstrapper: 新增 SYNC_TTL_DAYS=30 与 db_sync_at.txt 时间戳 (lastSyncAt/isSyncDue/syncRemainingMs/syncStatusText/markSynced) - initializeDatabase: 本地曲库可用且未到期时跳过服务器同步(无网络请求); 到期或本地不可用才查版本并在有更新时下载 - 设置页: 曲库行显示「30 天同步: N 天后/已到期」; 更新按钮改为手动强制检查 - download(): 成功后记录同步时间(去掉一次多余的远端版本请求) --- AGENTS.md | 1 + .../com/local/ktv/DatabaseBootstrapper.kt | 55 +++++++++-- .../main/java/com/local/ktv/MainActivity.kt | 91 ++++++++++++++++--- 3 files changed, 124 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f3395c3..e448759 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,5 +96,6 @@ $BT/apksigner verify 目标名.apk - **歌曲源**:`app/src/main/assets/mobile/ktv_api.js` 的 `CONFIG.HOSTS`(多源自动排序,失败源沉底) - **JS 热更新**:改动 `ktv_api.js` 必须同步提升 `KTV_BRIDGE_API: N` 标记(JS 与 `KtvJsBridge.kt` 两处校验),否则设备仍用缓存旧脚本 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件 +- **曲库同步时效**:默认 **30 天**(`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载) - **麦克风**:`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 - **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403) diff --git a/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt b/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt index d6df854..7f916f3 100644 --- a/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt +++ b/app/src/main/java/com/local/ktv/DatabaseBootstrapper.kt @@ -9,12 +9,55 @@ import java.util.Locale * * 说明:已移除分片/镜像数据库下载路径(database_publish 不再发布到代码仓库, * 代码仓库此后只存代码)。曲库统一从官方 Muse 服务(mconn.cherryonline.cn mls-api) - * 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类只做版本文件与可读错误包装。 + * 同步,由 [MuseDbSync] 负责登录、同步、校验与安装;本类负责同步时效、版本文件 + * 与可读错误包装。 + * + * 同步时效:默认 **30 天**([SYNC_TTL_DAYS])。未到期时启动不联网同步,直接用 + * 本地曲库;到期后才向服务器查询版本并在有更新时下载。 */ object 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 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。 */ fun fetchRemoteVersion(): String? = MuseDbSync.fetchVersion() @@ -23,16 +66,12 @@ object DatabaseBootstrapper { versionFile.takeIf { it.exists() }?.readText()?.trim()?.takeIf { it.isNotEmpty() } }.getOrNull() - private fun saveLocalDbVersion(version: String) { - runCatching { versionFile.writeText(version.trim()) } - } - - /** 下载曲库:仅官方 Muse 同步源。 */ + /** 下载曲库:仅官方 Muse 同步源;成功后记录同步时间(重置 30 天时效)。 */ fun download(onProgress: (Int) -> Unit): Result { return runCatching { val file = MuseDbSync.download(onProgress) - // MuseDbSync 内部已保存版本;此处幂等兜底。 - MuseDbSync.fetchVersion()?.let(::saveLocalDbVersion) + // MuseDbSync 内部已保存版本文件;这里记录同步时间作为时效起点。 + markSynced() file }.mapError(::readableError) } diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index b4afe1a..e96630f 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -586,23 +586,37 @@ class MainActivity : AppCompatActivity() { databaseBootstrapRunning = true showDatabaseLoading(true, "正在初始化曲库...", null) io.execute { - val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion() - val localVersion = DatabaseBootstrapper.getLocalDbVersion() - val updateRequired = remoteVersion != null && remoteVersion != localVersion var bootstrapError: Throwable? = null var usedOldDatabase = false - var ok = if (updateRequired) false else library.muse.open() - if (!ok) { - 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 + // 先尝试打开本地曲库;仅当本地不可用或已过 30 天时效时才联网同步。 + var ok = library.muse.open() + val syncDue = DatabaseBootstrapper.isSyncDue() + if (!ok || syncDue) { + val reason = if (!ok) "本地曲库不可用" else "已到 ${DatabaseBootstrapper.SYNC_TTL_DAYS} 天同步时效" + Log.i(TAG, "触发曲库服务器同步:$reason") + val remoteVersion = DatabaseBootstrapper.fetchRemoteVersion() + if (remoteVersion != null) { + val localVersion = DatabaseBootstrapper.getLocalDbVersion() + 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 @@ -8274,7 +8288,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_setting_storage_space, "预留存储空间", "当前 ${String.format(Locale.ROOT, "%.1f", reserveStorageGb)} GB") { showReserveStorageDialog() }, @@ -8315,9 +8330,55 @@ class MainActivity : AppCompatActivity() { io.execute { val count = runCatching { library.muse.songCount() }.getOrDefault(0) val downloaded = runCatching { countDownloadedFiles() }.getOrDefault(0) + // 同步时效(30 天): 未到期不联网, 到期后才触发服务器同步。 + val syncText = DatabaseBootstrapper.syncStatusText() main.post { 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()}") } } }