Fix backup restore dropping library entries (#3667)

* Fix backup restore dropping library entries

History restore resolved chapters by URL alone, so a backup holding the
same entry twice under one source matched multiple chapter rows and threw
`ResultSet returned more than 1 row`. Scope the lookups to the entry.

Entries are restored in chunks of 100 sharing one transaction. SQLDelight
fails the enclosing transaction when a nested one fails, so catching the
per-entry exception did not contain it and the whole chunk rolled back,
losing up to 100 entries per bad one. Retry entry by entry on failure.

Refs #647

* Update changelog

---------

Co-authored-by: Naji Astier <na-ji@users.noreply.github.com>
This commit is contained in:
Naji Astier
2026-08-01 17:26:24 +02:00
committed by GitHub
parent 4296d056b5
commit c114028a33
5 changed files with 29 additions and 8 deletions
+1
View File
@@ -33,6 +33,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
- Fixed app bars remaining visible after changing pages by tapping in the paged reader after using the chapter navigator slider ([@AntsyLich](https://github.com/AntsyLich)) ([#3567](https://github.com/mihonapp/mihon/pull/3567)) - Fixed app bars remaining visible after changing pages by tapping in the paged reader after using the chapter navigator slider ([@AntsyLich](https://github.com/AntsyLich)) ([#3567](https://github.com/mihonapp/mihon/pull/3567))
- Fixed MangaBaka User Agent string ([@MajorTanya](https://github.com/MajorTanya)) ([#3578](https://github.com/mihonapp/mihon/pull/3578)) - Fixed MangaBaka User Agent string ([@MajorTanya](https://github.com/MajorTanya)) ([#3578](https://github.com/mihonapp/mihon/pull/3578))
- Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3630](https://github.com/mihonapp/mihon/pull/3630)) - Fixed extension installation with shizuku installer ([@NGB-Was-Taken](https://github.com/NGB-Was-Taken)) ([#3630](https://github.com/mihonapp/mihon/pull/3630))
- Fixed backup restore dropping library entries when the backup contains duplicate chapters ([@na-ji](https://github.com/na-ji)) ([#3667](https://github.com/mihonapp/mihon/pull/3667))
## [v0.20.1] - 2026-07-09 ## [v0.20.1] - 2026-07-09
### Added ### Added
@@ -148,13 +148,30 @@ class BackupRestorer(
mangaRestorer.sortByNew(backupMangas) mangaRestorer.sortByNew(backupMangas)
.chunked(100) .chunked(100)
.forEach { chunk -> .forEach { chunk ->
database.transaction { val restoredAsBatch = try {
database.transaction {
chunk.forEach {
ensureActive()
mangaRestorer.restore(it, backupCategories)
}
}
true
} catch (e: Exception) {
ensureActive()
logcat(LogPriority.WARN, e) { "Batch restore failed, retrying entry by entry" }
false
}
if (restoredAsBatch) {
restoreProgress.addAndFetch(chunk.size)
} else {
chunk.forEach { chunk.forEach {
ensureActive() ensureActive()
try { try {
mangaRestorer.restore(it, backupCategories) mangaRestorer.restore(it, backupCategories)
} catch (e: Exception) { } catch (e: Exception) {
ensureActive()
val sourceName = sourceMapping[it.source] ?: it.source.toString() val sourceName = sourceMapping[it.source] ?: it.source.toString()
errors.add(Date() to "${it.title} [$sourceName]: ${e.message}") errors.add(Date() to "${it.title} [$sourceName]: ${e.message}")
} }
@@ -162,6 +179,7 @@ class BackupRestorer(
restoreProgress.incrementAndFetch() restoreProgress.incrementAndFetch()
} }
} }
notifier.showRestoreProgress(chunk.last().title, restoreProgress.load(), restoreAmount, isSync) notifier.showRestoreProgress(chunk.last().title, restoreProgress.load(), restoreAmount, isSync)
} }
} }
@@ -284,7 +284,7 @@ class MangaRestorer(
restoreCategories(manga, categories, backupCategories) restoreCategories(manga, categories, backupCategories)
restoreChapters(manga, chapters) restoreChapters(manga, chapters)
restoreTracking(manga, tracks) restoreTracking(manga, tracks)
restoreHistory(history) restoreHistory(manga, history)
restoreExcludedScanlators(manga, excludedScanlators) restoreExcludedScanlators(manga, excludedScanlators)
updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow) updateManga.awaitUpdateFetchInterval(manga, now, currentFetchWindow)
return manga return manga
@@ -324,16 +324,16 @@ class MangaRestorer(
} }
} }
private suspend fun restoreHistory(backupHistory: List<BackupHistory>) { private suspend fun restoreHistory(manga: Manga, backupHistory: List<BackupHistory>) {
val toUpdate = backupHistory.mapNotNull { history -> val toUpdate = backupHistory.mapNotNull { history ->
val dbHistory = database.historyQueries val dbHistory = database.historyQueries
.getHistoryByChapterUrl(history.url) .getHistoryByChapterUrlAndMangaId(history.url, manga.id)
.awaitAsOneOrNull() .awaitAsOneOrNull()
val item = history.getHistoryImpl() val item = history.getHistoryImpl()
if (dbHistory == null) { if (dbHistory == null) {
val chapter = database.chaptersQueries val chapter = database.chaptersQueries
.getChapterByUrl(history.url) .getChapterByUrlAndMangaId(history.url, manga.id)
.awaitAsOneOrNull() .awaitAsOneOrNull()
return@mapNotNull if (chapter == null) { return@mapNotNull if (chapter == null) {
// Chapter doesn't exist; skip // Chapter doesn't exist; skip
@@ -88,7 +88,8 @@ getChapterByUrlAndMangaId:
SELECT * SELECT *
FROM chapters FROM chapters
WHERE url = :chapterUrl WHERE url = :chapterUrl
AND manga_id = :mangaId; AND manga_id = :mangaId
LIMIT 1;
removeChaptersWithIds: removeChaptersWithIds:
DELETE FROM chapters DELETE FROM chapters
@@ -23,7 +23,7 @@ JOIN chapters C
ON H.chapter_id = C._id ON H.chapter_id = C._id
WHERE C.manga_id = :mangaId AND C._id = H.chapter_id; WHERE C.manga_id = :mangaId AND C._id = H.chapter_id;
getHistoryByChapterUrl: getHistoryByChapterUrlAndMangaId:
SELECT SELECT
H._id, H._id,
H.chapter_id, H.chapter_id,
@@ -32,7 +32,8 @@ H.time_read
FROM history H FROM history H
JOIN chapters C JOIN chapters C
ON H.chapter_id = C._id ON H.chapter_id = C._id
WHERE C.url = :chapterUrl AND C._id = H.chapter_id; WHERE C.url = :chapterUrl AND C.manga_id = :mangaId AND C._id = H.chapter_id
LIMIT 1;
resetHistoryById: resetHistoryById:
UPDATE history UPDATE history