Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,18 @@ internal class CustomLyricsManager(
}

/**
* Streams a bounded ZIP backup from [input] into a merge-restore: backup
* entries overwrite same-ID current entries, current-only IDs are kept,
* every restored entry gets a fresh remote fileId. TTML bodies are
* validated and written one at a time, never all at once. Consumes and
* closes [input].
* Streams a bounded ZIP backup from [input] into a merge-restore. Under
* [CustomLyricsRestorePolicy.OVERWRITE] backup entries overwrite same-ID
* current entries; under [CustomLyricsRestorePolicy.KEEP_EXISTING]
* same-ID conflicts keep the current entry. Current-only IDs are kept and
* backup-only IDs are appended under either policy; every restored entry
* gets a fresh remote fileId. TTML bodies are validated and written one
* at a time, never all at once. Consumes and closes [input].
*/
fun restore(input: InputStream): CustomLyricsRestoreResult = synchronized(mutationLock) {
fun restore(
input: InputStream,
policy: CustomLyricsRestorePolicy = CustomLyricsRestorePolicy.OVERWRITE,
): CustomLyricsRestoreResult = synchronized(mutationLock) {
if (!isWritable()) return CustomLyricsRestoreResult.Failed("libxposed remote file 服务不可用")
val state = configStore.indexState(snapshot)
return CustomLyricsRestoreTransaction(
Expand All @@ -123,7 +128,7 @@ internal class CustomLyricsManager(
deleteRemoteFile = { fileId ->
if (ModuleApplication.isCurrentSnapshot(snapshot)) snapshot.deleteRemoteFile(fileId)
},
).merge(state.manifest) { onFile -> CustomLyricsBackupCodec.decode(input, onFile) }
).merge(state.manifest, policy) { onFile -> CustomLyricsBackupCodec.decode(input, onFile) }
}

private fun readRemoteFile(fileId: String): ByteArray? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,30 @@ internal sealed interface CustomLyricsRestoreResult {
}

/**
* Merge-restore semantics: backup entries overwrite current entries with the
* same Apple Music ID, current-only IDs are kept. The backup's files arrive
* one at a time through the caller-supplied stream; every one is written to a
* fresh remote fileId as it arrives, the merged manifest is published once
* after the whole backup scans, then old files of overwritten IDs are
* deleted. Any scan, write, or publish failure rolls back only the new files
* and leaves the old manifest and old files untouched. An empty backup is a
* successful no-op.
* Conflict strategy applied per Apple Music ID when a restore meets both a
* current entry and a backup entry with the same ID.
*/
internal enum class CustomLyricsRestorePolicy {
/** Same-ID conflicts take the backup entry; the overwritten current file is retired. */
OVERWRITE,

/** Same-ID conflicts keep the current entry; the written backup file is dropped after publish. */
KEEP_EXISTING,
}

/**
* Merge-restore semantics: under [CustomLyricsRestorePolicy.OVERWRITE] backup
* entries overwrite current entries with the same Apple Music ID; under
* [CustomLyricsRestorePolicy.KEEP_EXISTING] same-ID conflicts keep the
* current entry. Current-only IDs are kept and backup-only IDs are appended
* under either policy. The backup's files arrive one at a time through the
* caller-supplied stream; every one is written to a fresh remote fileId as it
* arrives, the merged manifest is published once after the whole backup
* scans, then files that no longer reference any entry are deleted: old files
* of overwritten IDs under OVERWRITE, written-but-dropped backup files under
* KEEP_EXISTING. Any scan, write, or publish failure rolls back only the new
* files and leaves the old manifest and old files untouched. An empty backup
* is a successful no-op.
*/
internal class CustomLyricsRestoreTransaction(
private val fileIdFactory: () -> String,
Expand All @@ -27,6 +43,7 @@ internal class CustomLyricsRestoreTransaction(
) {
fun merge(
oldManifest: CustomLyricsManifest,
policy: CustomLyricsRestorePolicy = CustomLyricsRestorePolicy.OVERWRITE,
streamBackup: (onFile: (fileId: String, bytes: ByteArray) -> Unit) -> CustomLyricsBackupDecodeResult,
): CustomLyricsRestoreResult {
val allocatedFileIds = oldManifest.entries.mapTo(mutableSetOf(), CustomLyricsEntry::fileId)
Expand Down Expand Up @@ -65,34 +82,46 @@ internal class CustomLyricsRestoreTransaction(
}
is CustomLyricsBackupDecodeResult.Decoded -> {
val backup = scan.backup
if (backup.manifest.entries.isEmpty()) {
return CustomLyricsRestoreResult.Restored(oldManifest)
}
val error = writeError
if (error != null) {
written.forEach { runCatching { deleteRemoteFile(it) } }
return CustomLyricsRestoreResult.Failed(error)
}
if (backup.manifest.entries.isEmpty()) {
written.forEach { runCatching { deleteRemoteFile(it) } }
return CustomLyricsRestoreResult.Restored(oldManifest)
}
val rebuilt = mutableListOf<CustomLyricsEntry>()
val incomingById = linkedMapOf<Long, CustomLyricsEntry>()
for (incoming in backup.manifest.entries) {
val fileId = newFileIds[incoming.fileId]
if (fileId == null) {
written.forEach { runCatching { deleteRemoteFile(it) } }
return CustomLyricsRestoreResult.Failed("备份内容缺失")
}
rebuilt += incoming.copy(fileId = fileId)
val entry = incoming.copy(fileId = fileId)
if (incomingById.putIfAbsent(entry.appleMusicId, entry) != null) {
written.forEach { runCatching { deleteRemoteFile(it) } }
return CustomLyricsRestoreResult.Failed("备份条目重复")
}
rebuilt += entry
}
val incomingById = rebuilt.associateBy(CustomLyricsEntry::appleMusicId)
val currentIds = oldManifest.entries.mapTo(mutableSetOf(), CustomLyricsEntry::appleMusicId)
val mergedEntries = mutableListOf<CustomLyricsEntry>()
val retiredFileIds = mutableListOf<String>()
val droppedFileIds = mutableListOf<String>()
oldManifest.entries.forEach { current ->
val replacement = incomingById[current.appleMusicId]
if (replacement != null) {
mergedEntries += replacement
retiredFileIds += current.fileId
} else {
mergedEntries += current
when {
replacement == null -> mergedEntries += current
policy == CustomLyricsRestorePolicy.OVERWRITE -> {
mergedEntries += replacement
retiredFileIds += current.fileId
}
else -> {
mergedEntries += current
droppedFileIds += replacement.fileId
}
}
}
rebuilt.forEach { entry ->
Expand All @@ -103,7 +132,7 @@ internal class CustomLyricsRestoreTransaction(
written.forEach { runCatching { deleteRemoteFile(it) } }
return CustomLyricsRestoreResult.Failed("无法发布歌词映射")
}
retiredFileIds.forEach { runCatching { deleteRemoteFile(it) } }
(retiredFileIds + droppedFileIds).forEach { runCatching { deleteRemoteFile(it) } }
CustomLyricsRestoreResult.Restored(merged)
}
}
Expand Down
22 changes: 14 additions & 8 deletions app/src/main/java/dev/amenhancer/module/ui/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import dev.amenhancer.module.lyrics.CustomLyricsMutationResult
import dev.amenhancer.module.lyrics.CustomLyricsOnlineImportResult
import dev.amenhancer.module.lyrics.CustomLyricsOnlineImporter
import dev.amenhancer.module.lyrics.CustomLyricsRestoreResult
import dev.amenhancer.module.lyrics.CustomLyricsRestorePolicy
import dev.amenhancer.module.lyrics.CustomLyricsSaveResult
import dev.amenhancer.module.model.CustomLyricsEntry
import dev.amenhancer.module.model.CustomLyricsManifest
Expand Down Expand Up @@ -761,22 +762,27 @@ class SettingsActivity : Activity() {

private fun confirmRestoreCustomLyrics(uri: android.net.Uri) {
AlertDialog.Builder(this)
.setTitle("合并恢复歌词备份")
.setMessage(
"同 Apple Music ID 的歌词将使用备份版本;" +
"当前独有歌词会保留;总开关不会改变。是否继续?",
)
.setTitle("恢复歌词备份")
.setMessage("覆盖:冲突歌词使用备份版本;不覆盖:冲突歌词保留当前版本。")
.setNegativeButton("取消", null)
.setPositiveButton("恢复") { _, _ -> restoreCustomLyrics(uri) }
.setNeutralButton("不覆盖") { _, _ ->
restoreCustomLyrics(uri, CustomLyricsRestorePolicy.KEEP_EXISTING)
}
.setPositiveButton("覆盖") { _, _ ->
restoreCustomLyrics(uri, CustomLyricsRestorePolicy.OVERWRITE)
}
.show()
}

private fun restoreCustomLyrics(uri: android.net.Uri) {
private fun restoreCustomLyrics(
uri: android.net.Uri,
policy: CustomLyricsRestorePolicy,
) {
val snapshot = ModuleApplication.serviceSnapshot
backgroundExecutor.execute {
val result = runCatching {
contentResolver.openInputStream(uri)?.use { input ->
CustomLyricsManager(snapshot, store).restore(input)
CustomLyricsManager(snapshot, store).restore(input, policy)
} ?: CustomLyricsRestoreResult.Failed("无法读取备份文件")
}.getOrElse { CustomLyricsRestoreResult.Failed("读取备份失败") }
runOnUiThread {
Expand Down
Loading
Loading