diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db7ee134..0980aea6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ### Fixed - Fix long floating point numbers showing up in Comick progress ([@MajorTanya](https://github.com/MajorTanya)) ([#4090](https://github.com/mihonapp/mihon/pull/4090)) +- Fixed updating from v0.20.x failing when the database had duplicate history or tracking entries, or damaged indexes ([@AntsyLich](https://github.com/AntsyLich)) ## [v0.21.1] - 2026-10-12 ### Fixed diff --git a/data/src/main/sqldelight/tachiyomi/migrations/17.sqm b/data/src/main/sqldelight/tachiyomi/migrations/17.sqm index 282d0726a..bde4e3f29 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/17.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/17.sqm @@ -4,6 +4,64 @@ import kotlin.String; import kotlin.collections.List; import kotlinx.serialization.json.JsonObject; +-- Every view reads `mangas`, so they are dropped here and recreated against the new tables at the end +DROP VIEW historyView; +DROP VIEW libraryView; +DROP VIEW updatesView; + +-- --------------------------------------------------------------------------------------------------------- +-- damaged indexes +-- --------------------------------------------------------------------------------------------------------- + +-- Some databases hold rows their unique constraints should have rejected, which a damaged index explains. A +-- write to a table also writes its indexes and fails on a damaged one, and a read through one can miss rows, +-- so each table changed in place below is first copied from its rows alone, leaving its indexes behind. +CREATE TABLE mangas_copy AS +SELECT + _id, source, url, artist, author, description, genre, title, status, thumbnail_url, last_update, next_update, + initialized, viewer, chapter_flags, cover_last_modified, update_strategy, calculate_interval, notes, memo, + favorite_at +FROM mangas NOT INDEXED; +DROP TABLE mangas; +ALTER TABLE mangas_copy RENAME TO mangas; + +CREATE TABLE chapters_copy AS +SELECT + _id, manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, + date_upload, memo +FROM chapters NOT INDEXED; +DROP TABLE chapters; +ALTER TABLE chapters_copy RENAME TO chapters; + +CREATE TABLE history_copy AS +SELECT _id, chapter_id, last_read, time_read +FROM history NOT INDEXED; +DROP TABLE history; +ALTER TABLE history_copy RENAME TO history; + +CREATE TABLE mangas_categories_copy AS +SELECT _id, manga_id, category_id +FROM mangas_categories NOT INDEXED; +DROP TABLE mangas_categories; +ALTER TABLE mangas_categories_copy RENAME TO mangas_categories; + +CREATE TABLE manga_sync_copy AS +SELECT + _id, manga_id, sync_id, remote_id, library_id, title, last_chapter_read, total_chapters, status, score, + remote_url, start_date, finish_date, private +FROM manga_sync NOT INDEXED; +DROP TABLE manga_sync; +ALTER TABLE manga_sync_copy RENAME TO manga_sync; + +CREATE TABLE excluded_scanlators_copy AS +SELECT manga_id, scanlator +FROM excluded_scanlators NOT INDEXED; +DROP TABLE excluded_scanlators; +ALTER TABLE excluded_scanlators_copy RENAME TO excluded_scanlators; + +-- Merging duplicate chapters looks their history up by chapter +CREATE INDEX history_chapter_id_index ON history(chapter_id); + -- --------------------------------------------------------------------------------------------------------- -- orphans -- --------------------------------------------------------------------------------------------------------- @@ -21,11 +79,6 @@ DELETE FROM excluded_scanlators WHERE manga_id NOT IN (SELECT _id FROM mangas); -- table - manga -- --------------------------------------------------------------------------------------------------------- --- Every view reads `mangas`, so they are dropped here and recreated against the new table below -DROP VIEW historyView; -DROP VIEW libraryView; -DROP VIEW updatesView; - -- Entries sharing a source and url are merged into one, since the new table won't hold both. The copies are -- ranked with the ones in the library first, since that is the entry the user has, then by age. The first -- one stays, takes each detail it is missing from the first copy that has it, and takes over everything @@ -326,8 +379,7 @@ CREATE INDEX manga_category_category_id_index ON manga_category(category_id); -- --------------------------------------------------------------------------------------------------------- -- `last_read` is when the chapter was last read and `time_read` is how long it has been read for, so --- both are renamed to say so. The chapter it belongs to is already unique, which leaves --- `history_history_chapter_id_index` a prefix of that constraint. +-- both are renamed to say so. The chapter it belongs to is already unique, so the constraint indexes it. -- The table keeps its name, so the original is renamed aside and the new one built under -- the real name; renaming a table into place makes SQLite rewrite its stored definition, -- leaving it spelled differently from the same table on a fresh install. The views were @@ -349,11 +401,14 @@ CREATE TABLE history( -- Reaching the entry a row belongs to meant going through its chapter; it names the entry -- itself now, taken from that same chapter. A row whose chapter is already gone cannot name -- one and is dropped, which is what the foreign key would do to it once it is enforced again. +-- Some databases hold more than one row for a chapter despite the old unique constraint, and the new one +-- rejects them, so they merge the way the history of duplicate chapters does above. INSERT INTO history(id, manga_id, chapter_id, read_at, read_duration) -SELECT H._id, C.manga_id, H.chapter_id, H.last_read, H.time_read +SELECT min(H._id), C.manga_id, H.chapter_id, max(H.last_read), sum(H.time_read) FROM history_temp H JOIN chapter C -ON C.id = H.chapter_id; +ON C.id = H.chapter_id +GROUP BY H.chapter_id; DROP TABLE history_temp; @@ -388,19 +443,20 @@ DROP TABLE excluded_scanlators; -- --------------------------------------------------------------------------------------------------------- -- `manga_sync` is what tracking was called when it was still called syncing; `sync_id` is the tracker. --- An entry that merged can leave two rows for the same tracker on the survivor, which the unique constraint --- rejects, so the one to keep is chosen first: one on a copy in the library, then the one furthest read, then --- the one on the copy ranked first, then the oldest. A track is taken whole rather than merged field by field, --- because two rows on the same tracker can point at different remote entries. +-- An entry that merged can leave two rows for the same tracker on the survivor, and an entry that didn't can +-- still hold two despite the old unique constraint. The new one rejects both, so the one to keep is chosen +-- first: one on a copy in the library, then the one furthest read, then the one on the copy ranked first, then +-- the oldest. A track is taken whole rather than merged field by field, because two rows on the same tracker +-- can point at different remote entries. CREATE TEMP TABLE duplicate_tracks AS SELECT T._id, first_value(T._id) OVER ( - PARTITION BY D.keep_id, T.sync_id + PARTITION BY coalesce(D.keep_id, T.manga_id), T.sync_id ORDER BY D.favorite DESC, T.last_chapter_read DESC, D.precedence ASC, T._id ASC ) AS keep_track_id FROM manga_sync T -JOIN duplicate_mangas D +LEFT JOIN duplicate_mangas D ON D._id = T.manga_id; DELETE FROM manga_sync