曲库同步时效 30 天: 未到期启动不联网, 到期后才同步服务器数据库

- DatabaseBootstrapper: 新增 SYNC_TTL_DAYS=30 与 db_sync_at.txt 时间戳
  (lastSyncAt/isSyncDue/syncRemainingMs/syncStatusText/markSynced)
- initializeDatabase: 本地曲库可用且未到期时跳过服务器同步(无网络请求);
  到期或本地不可用才查版本并在有更新时下载
- 设置页: 曲库行显示「30 天同步: N 天后/已到期」; 更新按钮改为手动强制检查
- download(): 成功后记录同步时间(去掉一次多余的远端版本请求)
This commit is contained in:
zijunchiang
2026-09-11 23:28:09 +08:00
parent 4c792361a4
commit b37dbec443
3 changed files with 124 additions and 23 deletions
+1
View File
@@ -96,5 +96,6 @@ $BT/apksigner verify 目标名.apk
- **歌曲源**`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`),不再依赖代码仓库数据库文件 - **曲库**:只走官方 Muse 同步(`MuseDbSync`),不再依赖代码仓库数据库文件
- **曲库同步时效**:默认 **30 天**`DatabaseBootstrapper.SYNC_TTL_DAYS`)。未到期启动**不联网**、直接用本地曲库;到期后才查询服务器版本,有更新才下载。时效起点记录在 `db_sync_at.txt`,由 `DatabaseBootstrapper.markSynced()` 写入(下载成功或校验为最新时)。设置页「曲库 → 更新」为手动强制检查(绕过时效,仅在服务器版本更新时下载)
- **麦克风**`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建 - **麦克风**`MicActivator` 常驻占用输入通道(TV/投影需占用后才有演唱声音输出),外接设备热插拔会自动重建
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403 - **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403
@@ -9,12 +9,55 @@ import java.util.Locale
* *
* 说明:已移除分片/镜像数据库下载路径(database_publish 不再发布到代码仓库, * 说明:已移除分片/镜像数据库下载路径(database_publish 不再发布到代码仓库,
* 代码仓库此后只存代码)。曲库统一从官方 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)
} }
+76 -15
View File
@@ -586,23 +586,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
@@ -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_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 +8330,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()}")
} }
} }
} }