From f1b3b943bccce6af8611b3dd2fd7fe17df6098c7 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:57:01 +0600 Subject: [PATCH 01/11] table - manga Rebuild `mangas` as `manga` with a UNIQUE(source_id, remote_url) constraint, `remote_`/`user_`/`state_` column prefixes and `_id` -> `id`. `remote_` is what the source reported, `user_` is what the user set, `state_` is local bookkeeping that is neither; `source_id` keeps its name because it identifies the source rather than carrying its data. Entries sharing a source and url are merged rather than dropped: the survivor absorbs the details, timestamps and children of the rows it takes over, and the duplicate chapters that repointing creates are merged too, read state and history included. Drop the sync scaffolding (`version`, `is_syncing`, `favorite_modified_at`) and collapse `favorite`, `date_added` and `favorite_modified_at` into a single nullable `user_favorite_at` - null means the entry is not in the library. The child tables keep pointing at the table this replaces until their own step rebuilds them. Foreign keys are off for the whole migration, so nothing reads those clauses in between, and copying every child table here just to rewrite one clause would double the work the migration does. For the same reason the children resolve the surviving entry while they are copied rather than being rewritten here first. Merging entries is also what creates duplicate chapters and duplicate tracks, but each of those is resolved in the step that rebuilds its own table, where the constraint that makes the collision matter is introduced. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../screen/advanced/ClearDatabaseScreen.kt | 2 +- .../backup/restore/restorers/MangaRestorer.kt | 17 +- app/src/main/java/mihon/app/di/AppBindings.kt | 10 +- .../tachiyomi/data/history/HistoryMapper.kt | 4 +- .../java/tachiyomi/data/manga/MangaMapper.kt | 183 +++++------ .../data/manga/MangaRepositoryImpl.kt | 33 +- .../data/source/SourceRepositoryImpl.kt | 4 +- .../data/updates/UpdatesRepositoryImpl.kt | 4 +- .../sqldelight/tachiyomi/data/chapters.sq | 18 +- .../tachiyomi/data/excluded_scanlators.sq | 2 +- .../main/sqldelight/tachiyomi/data/history.sq | 6 +- .../main/sqldelight/tachiyomi/data/manga.sq | 262 ++++++++++++++++ .../sqldelight/tachiyomi/data/manga_sync.sq | 2 +- .../main/sqldelight/tachiyomi/data/mangas.sq | 293 ------------------ .../tachiyomi/data/mangas_categories.sq | 9 +- .../sqldelight/tachiyomi/migrations/14.sqm | 234 ++++++++++++++ .../sqldelight/tachiyomi/view/historyView.sq | 24 +- .../sqldelight/tachiyomi/view/libraryView.sq | 8 +- .../sqldelight/tachiyomi/view/updatesView.sq | 22 +- 19 files changed, 643 insertions(+), 494 deletions(-) create mode 100644 data/src/main/sqldelight/tachiyomi/data/manga.sq delete mode 100644 data/src/main/sqldelight/tachiyomi/data/mangas.sq create mode 100644 data/src/main/sqldelight/tachiyomi/migrations/14.sqm diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt index 9e89672da41..ded7b2e11d1 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt @@ -253,7 +253,7 @@ class ClearDatabaseViewModel( suspend fun removeMangaBySourceId(keepReadManga: Boolean) = withNonCancellableContext { val state = state.value as? State.Ready ?: return@withNonCancellableContext - database.mangasQueries.deleteNonLibraryManga(state.selection, keepReadManga.toLong()) + database.mangaQueries.deleteNonLibraryManga(state.selection, keepReadManga.toLong()) database.historyQueries.removeResettedHistory() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 2c12099c640..8054964736d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -45,10 +45,10 @@ class MangaRestorer( private val currentFetchWindow = fetchInterval.getWindow(now.date, timeZone) suspend fun sortByNew(backupMangas: List): List { - val urlsBySource = database.mangasQueries + val urlsBySource = database.mangaQueries .getAllMangaSourceAndUrl() .awaitAsList() - .groupBy({ it.source }, { it.url }) + .groupBy({ it.source_id }, { it.remote_url }) return backupMangas .sortedWith( @@ -109,7 +109,7 @@ class MangaRestorer( } private suspend fun updateManga(manga: Manga): Manga { - database.mangasQueries.update( + database.mangaQueries.update( source = manga.source, url = manga.url, artist = manga.artist, @@ -119,7 +119,7 @@ class MangaRestorer( title = manga.title, status = manga.status, thumbnailUrl = manga.thumbnailUrl, - favorite = manga.favorite, + favoriteAt = manga.dateAdded.takeIf { manga.favorite }, lastUpdate = manga.lastUpdate, nextUpdate = null, calculateInterval = null, @@ -127,11 +127,8 @@ class MangaRestorer( viewer = manga.viewerFlags, chapterFlags = manga.chapterFlags, coverLastModified = manga.coverLastModified, - dateAdded = manga.dateAdded, mangaId = manga.id, updateStrategy = manga.updateStrategy.let(UpdateStrategyColumnAdapter::encode), - version = manga.version, - isSyncing = 1, notes = manga.notes, memo = manga.memo.let(MemoColumnAdapter::encode), ) @@ -243,7 +240,7 @@ class MangaRestorer( * @return id of [Manga], null if not found */ private suspend fun insertManga(manga: Manga): Long { - return database.mangasQueries.insertReturningId( + return database.mangaQueries.insertReturningId( source = manga.source, url = manga.url, artist = manga.artist, @@ -253,7 +250,7 @@ class MangaRestorer( title = manga.title, status = manga.status, thumbnailUrl = manga.thumbnailUrl, - favorite = manga.favorite, + favoriteAt = manga.dateAdded.takeIf { manga.favorite }, lastUpdate = manga.lastUpdate, nextUpdate = 0L, calculateInterval = 0L, @@ -261,9 +258,7 @@ class MangaRestorer( viewerFlags = manga.viewerFlags, chapterFlags = manga.chapterFlags, coverLastModified = manga.coverLastModified, - dateAdded = manga.dateAdded, updateStrategy = manga.updateStrategy, - version = manga.version, notes = manga.notes, memo = manga.memo, ) diff --git a/app/src/main/java/mihon/app/di/AppBindings.kt b/app/src/main/java/mihon/app/di/AppBindings.kt index 219bec558c7..91951215d9a 100644 --- a/app/src/main/java/mihon/app/di/AppBindings.kt +++ b/app/src/main/java/mihon/app/di/AppBindings.kt @@ -20,7 +20,7 @@ import tachiyomi.data.Chapters import tachiyomi.data.Database import tachiyomi.data.DateColumnAdapter import tachiyomi.data.History -import tachiyomi.data.Mangas +import tachiyomi.data.Manga import tachiyomi.data.MemoColumnAdapter import tachiyomi.data.StringListColumnAdapter import tachiyomi.data.UpdateStrategyColumnAdapter @@ -49,10 +49,10 @@ object AppBindings { historyAdapter = History.Adapter( last_readAdapter = DateColumnAdapter, ), - mangasAdapter = Mangas.Adapter( - genreAdapter = StringListColumnAdapter, - update_strategyAdapter = UpdateStrategyColumnAdapter, - memoAdapter = MemoColumnAdapter, + mangaAdapter = Manga.Adapter( + remote_genreAdapter = StringListColumnAdapter, + remote_update_strategyAdapter = UpdateStrategyColumnAdapter, + remote_memoAdapter = MemoColumnAdapter, ), chaptersAdapter = Chapters.Adapter( memoAdapter = MemoColumnAdapter, diff --git a/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt b/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt index 709c5800d85..f2aecd84c97 100644 --- a/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt +++ b/data/src/main/java/tachiyomi/data/history/HistoryMapper.kt @@ -25,7 +25,7 @@ object HistoryMapper { title: String, thumbnailUrl: String?, sourceId: Long, - isFavorite: Boolean, + favoriteAt: Long?, coverLastModified: Long, chapterNumber: Double, readAt: Date?, @@ -41,7 +41,7 @@ object HistoryMapper { coverData = MangaCover( mangaId = mangaId, sourceId = sourceId, - isMangaFavorite = isFavorite, + isMangaFavorite = favoriteAt != null, url = thumbnailUrl, lastModified = coverLastModified, ), diff --git a/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt b/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt index bfa819a241d..479a817e78d 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt @@ -7,43 +7,38 @@ import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.MangaWithChapterCount object MangaMapper { - @Suppress("UNUSED_PARAMETER") fun mapManga( id: Long, - source: Long, + sourceId: Long, url: String, - artist: String?, + title: String, author: String?, + artist: String?, description: String?, genre: List?, - title: String, status: Long, - thumbnailUrl: String?, - favorite: Boolean, - lastUpdate: Long?, - nextUpdate: Long?, - initialized: Boolean, - viewerFlags: Long, - chapterFlags: Long, - coverLastModified: Long, - dateAdded: Long, + cover: String?, updateStrategy: UpdateStrategy, - calculateInterval: Long, - lastModifiedAt: Long, - favoriteModifiedAt: Long?, - version: Long, - isSyncing: Long, - notes: String, memo: JsonObject, + favoriteAt: Long?, + notes: String, + readerFlags: Long, + chapterFlags: Long, + chapterLastUpdate: Long?, + chapterNextUpdate: Long?, + chapterFetchInterval: Long, + coverLastModified: Long, + initialized: Boolean, + modifiedAt: Long, ): Manga = Manga( id = id, - source = source, - favorite = favorite, - lastUpdate = lastUpdate ?: 0, - nextUpdate = nextUpdate ?: 0, - fetchInterval = calculateInterval.toInt(), - dateAdded = dateAdded, - viewerFlags = viewerFlags, + source = sourceId, + favorite = favoriteAt != null, + lastUpdate = chapterLastUpdate ?: 0, + nextUpdate = chapterNextUpdate ?: 0, + fetchInterval = chapterFetchInterval.toInt(), + dateAdded = favoriteAt ?: 0, + viewerFlags = readerFlags, chapterFlags = chapterFlags, coverLastModified = coverLastModified, url = url, @@ -53,43 +48,39 @@ object MangaMapper { description = description, genre = genre, status = status, - thumbnailUrl = thumbnailUrl, + thumbnailUrl = cover, updateStrategy = updateStrategy, initialized = initialized, - lastModifiedAt = lastModifiedAt, - favoriteModifiedAt = favoriteModifiedAt, - version = version, + lastModifiedAt = modifiedAt, + favoriteModifiedAt = favoriteAt, + version = 0, notes = notes, memo = memo, ) fun mapLibraryManga( id: Long, - source: Long, + sourceId: Long, url: String, - artist: String?, + title: String, author: String?, + artist: String?, description: String?, genre: List?, - title: String, status: Long, - thumbnailUrl: String?, - favorite: Boolean, - lastUpdate: Long?, - nextUpdate: Long?, - initialized: Boolean, - viewerFlags: Long, - chapterFlags: Long, - coverLastModified: Long, - dateAdded: Long, + cover: String?, updateStrategy: UpdateStrategy, - calculateInterval: Long, - lastModifiedAt: Long, - favoriteModifiedAt: Long?, - version: Long, - isSyncing: Long, - notes: String, memo: JsonObject, + favoriteAt: Long, + notes: String, + readerFlags: Long, + chapterFlags: Long, + chapterLastUpdate: Long?, + chapterNextUpdate: Long?, + chapterFetchInterval: Long, + coverLastModified: Long, + initialized: Boolean, + modifiedAt: Long, totalCount: Long, readCount: Double, latestUpload: Long, @@ -100,31 +91,27 @@ object MangaMapper { ): LibraryManga = LibraryManga( manga = mapManga( id, - source, + sourceId, url, - artist, + title, author, + artist, description, genre, - title, status, - thumbnailUrl, - favorite, - lastUpdate, - nextUpdate, - initialized, - viewerFlags, - chapterFlags, - coverLastModified, - dateAdded, + cover, updateStrategy, - calculateInterval, - lastModifiedAt, - favoriteModifiedAt, - version, - isSyncing, - notes, memo, + favoriteAt, + notes, + readerFlags, + chapterFlags, + chapterLastUpdate, + chapterNextUpdate, + chapterFetchInterval, + coverLastModified, + initialized, + modifiedAt, ), categories = categories.split(",").map { it.toLong() }, totalChapters = totalCount, @@ -137,60 +124,52 @@ object MangaMapper { fun mapMangaWithChapterCount( id: Long, - source: Long, + sourceId: Long, url: String, - artist: String?, + title: String, author: String?, + artist: String?, description: String?, genre: List?, - title: String, status: Long, - thumbnailUrl: String?, - favorite: Boolean, - lastUpdate: Long?, - nextUpdate: Long?, - initialized: Boolean, - viewerFlags: Long, - chapterFlags: Long, - coverLastModified: Long, - dateAdded: Long, + cover: String?, updateStrategy: UpdateStrategy, - calculateInterval: Long, - lastModifiedAt: Long, - favoriteModifiedAt: Long?, - version: Long, - isSyncing: Long, - notes: String, memo: JsonObject, + favoriteAt: Long?, + notes: String, + readerFlags: Long, + chapterFlags: Long, + chapterLastUpdate: Long?, + chapterNextUpdate: Long?, + chapterFetchInterval: Long, + coverLastModified: Long, + initialized: Boolean, + modifiedAt: Long, totalCount: Long, ): MangaWithChapterCount = MangaWithChapterCount( manga = mapManga( id, - source, + sourceId, url, - artist, + title, author, + artist, description, genre, - title, status, - thumbnailUrl, - favorite, - lastUpdate, - nextUpdate, - initialized, - viewerFlags, - chapterFlags, - coverLastModified, - dateAdded, + cover, updateStrategy, - calculateInterval, - lastModifiedAt, - favoriteModifiedAt, - version, - isSyncing, - notes, memo, + favoriteAt, + notes, + readerFlags, + chapterFlags, + chapterLastUpdate, + chapterNextUpdate, + chapterFetchInterval, + coverLastModified, + initialized, + modifiedAt, ), chapterCount = totalCount, ) diff --git a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt index f39bbf83c02..1348ecf9763 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt @@ -35,37 +35,37 @@ class MangaRepositoryImpl( ) : MangaRepository { override suspend fun getMangaById(id: Long): Manga { - return database.mangasQueries + return database.mangaQueries .getMangaById(id, MangaMapper::mapManga) .awaitAsOne() } override fun getMangaByIdAsFlow(id: Long): Flow { - return database.mangasQueries + return database.mangaQueries .getMangaById(id, MangaMapper::mapManga) .subscribeToOne() } override suspend fun getMangaByUrlAndSourceId(url: String, sourceId: Long): Manga? { - return database.mangasQueries + return database.mangaQueries .getMangaByUrlAndSource(url, sourceId, MangaMapper::mapManga) .awaitAsOneOrNull() } override fun getMangaByUrlAndSourceIdAsFlow(url: String, sourceId: Long): Flow { - return database.mangasQueries + return database.mangaQueries .getMangaByUrlAndSource(url, sourceId, MangaMapper::mapManga) .subscribeToOneOrNull() } override suspend fun getFavorites(): List { - return database.mangasQueries + return database.mangaQueries .getFavorites(MangaMapper::mapManga) .awaitAsList() } override suspend fun getReadMangaNotInLibrary(): List { - return database.mangasQueries + return database.mangaQueries .getReadMangaNotInLibrary(MangaMapper::mapManga) .awaitAsList() } @@ -83,13 +83,13 @@ class MangaRepositoryImpl( } override fun getFavoritesBySourceId(sourceId: Long): Flow> { - return database.mangasQueries + return database.mangaQueries .getFavoriteBySourceId(sourceId, MangaMapper::mapManga) .subscribeToList() } override suspend fun getDuplicateLibraryManga(id: Long, title: String): List { - return database.mangasQueries + return database.mangaQueries .getDuplicateLibraryManga(id, title, MangaMapper::mapMangaWithChapterCount) .awaitAsList() } @@ -102,7 +102,7 @@ class MangaRepositoryImpl( val timeZone = TimeZone.currentSystemDefault() val epochMillis = Clock.System.now().toLocalDateTime(timeZone).date.atStartOfDayIn(timeZone).toEpochMilliseconds() - return database.mangasQueries + return database.mangaQueries .getUpcomingManga( startOfDay = epochMillis, statuses = statuses, @@ -117,7 +117,7 @@ class MangaRepositoryImpl( override suspend fun resetViewerFlags(): Boolean { return try { - database.mangasQueries.resetViewerFlags() + database.mangaQueries.resetViewerFlags() true } catch (e: Exception) { logcat(LogPriority.ERROR, e) @@ -157,7 +157,7 @@ class MangaRepositoryImpl( override suspend fun insertNetworkManga(manga: List): List { return database.transactionWithResult { manga.map { - database.mangasQueries.insertNetworkManga( + database.mangaQueries.insertNetworkManga( source = it.source, url = it.url, artist = it.artist, @@ -167,7 +167,7 @@ class MangaRepositoryImpl( title = it.title, status = it.status, thumbnailUrl = it.thumbnailUrl, - favorite = it.favorite, + favoriteAt = it.dateAdded.takeIf { _ -> it.favorite }, lastUpdate = it.lastUpdate, nextUpdate = it.nextUpdate, calculateInterval = it.fetchInterval.toLong(), @@ -175,9 +175,7 @@ class MangaRepositoryImpl( viewerFlags = it.viewerFlags, chapterFlags = it.chapterFlags, coverLastModified = it.coverLastModified, - dateAdded = it.dateAdded, updateStrategy = it.updateStrategy, - version = it.version, memo = it.memo, updateTitle = it.title.isNotBlank(), updateCover = !it.thumbnailUrl.isNullOrBlank(), @@ -192,7 +190,7 @@ class MangaRepositoryImpl( private suspend fun partialUpdate(vararg mangaUpdates: MangaUpdate) { database.transaction { mangaUpdates.forEach { value -> - database.mangasQueries.update( + database.mangaQueries.update( source = value.source, url = value.url, artist = value.artist, @@ -202,7 +200,7 @@ class MangaRepositoryImpl( title = value.title, status = value.status, thumbnailUrl = value.thumbnailUrl, - favorite = value.favorite, + favoriteAt = value.dateAdded?.takeIf { it != 0L }, lastUpdate = value.lastUpdate, nextUpdate = value.nextUpdate, calculateInterval = value.fetchInterval?.toLong(), @@ -210,11 +208,8 @@ class MangaRepositoryImpl( viewer = value.viewerFlags, chapterFlags = value.chapterFlags, coverLastModified = value.coverLastModified, - dateAdded = value.dateAdded, mangaId = value.id, updateStrategy = value.updateStrategy?.let(UpdateStrategyColumnAdapter::encode), - version = value.version, - isSyncing = 0, notes = value.notes, memo = value.memo?.let(MemoColumnAdapter::encode), ) diff --git a/data/src/main/java/tachiyomi/data/source/SourceRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/source/SourceRepositoryImpl.kt index f6c88b2f0af..f746a806f35 100644 --- a/data/src/main/java/tachiyomi/data/source/SourceRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/source/SourceRepositoryImpl.kt @@ -48,7 +48,7 @@ class SourceRepositoryImpl( } override fun getSourcesWithFavoriteCount(): Flow>> { - val sourceIdWithFavoriteCountFlow = database.mangasQueries + val sourceIdWithFavoriteCountFlow = database.mangaQueries .getSourceIdWithFavoriteCount() .subscribeToList() return combine(sourceIdWithFavoriteCountFlow, sourceManager.sources) { sourceIdWithFavoriteCount, _ -> @@ -66,7 +66,7 @@ class SourceRepositoryImpl( } override fun getSourcesWithNonLibraryManga(): Flow> { - return database.mangasQueries + return database.mangaQueries .getSourceIdsWithNonLibraryManga() .subscribeToList() .map { sourceId -> diff --git a/data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt index 651143628ea..d1327ada103 100644 --- a/data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/updates/UpdatesRepositoryImpl.kt @@ -89,7 +89,7 @@ class UpdatesRepositoryImpl( bookmark: Boolean, lastPageRead: Long, sourceId: Long, - favorite: Boolean, + favoriteAt: Long, thumbnailUrl: String?, coverLastModified: Long, dateUpload: Long, @@ -110,7 +110,7 @@ class UpdatesRepositoryImpl( coverData = MangaCover( mangaId = mangaId, sourceId = sourceId, - isMangaFavorite = favorite, + isMangaFavorite = favoriteAt != 0L, url = thumbnailUrl, lastModified = coverLastModified, ), diff --git a/data/src/main/sqldelight/tachiyomi/data/chapters.sq b/data/src/main/sqldelight/tachiyomi/data/chapters.sq index b6280be75e8..9b03308e211 100644 --- a/data/src/main/sqldelight/tachiyomi/data/chapters.sq +++ b/data/src/main/sqldelight/tachiyomi/data/chapters.sq @@ -18,7 +18,7 @@ CREATE TABLE chapters( version INTEGER NOT NULL DEFAULT 0, is_syncing INTEGER NOT NULL DEFAULT 0, memo BLOB AS JsonObject NOT NULL DEFAULT '{}', - FOREIGN KEY(manga_id) REFERENCES mangas (_id) + FOREIGN KEY(manga_id) REFERENCES manga (id) ON DELETE CASCADE ); @@ -35,22 +35,6 @@ BEGIN WHERE _id = new._id; END; -CREATE TRIGGER update_chapter_and_manga_version AFTER UPDATE ON chapters -WHEN new.is_syncing = 0 AND ( - new.read != old.read OR - new.bookmark != old.bookmark OR - new.last_page_read != old.last_page_read -) -BEGIN - -- Update the chapter version - UPDATE chapters SET version = version + 1 - WHERE _id = new._id; - - -- Update the manga version - UPDATE mangas SET version = version + 1 - WHERE _id = new.manga_id AND (SELECT is_syncing FROM mangas WHERE _id = new.manga_id) = 0; -END; - getChapterById: SELECT * FROM chapters diff --git a/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq b/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq index 37ed6489ec4..b0a42ff3ec2 100644 --- a/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq +++ b/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq @@ -1,7 +1,7 @@ CREATE TABLE excluded_scanlators( manga_id INTEGER NOT NULL, scanlator TEXT NOT NULL, - FOREIGN KEY(manga_id) REFERENCES mangas (_id) + FOREIGN KEY(manga_id) REFERENCES manga (id) ON DELETE CASCADE ); diff --git a/data/src/main/sqldelight/tachiyomi/data/history.sq b/data/src/main/sqldelight/tachiyomi/data/history.sq index 6cecd5784df..4e459db004b 100644 --- a/data/src/main/sqldelight/tachiyomi/data/history.sq +++ b/data/src/main/sqldelight/tachiyomi/data/history.sq @@ -45,12 +45,12 @@ UPDATE history SET last_read = 0 WHERE _id IN ( SELECT H._id - FROM mangas M + FROM manga M INNER JOIN chapters C - ON M._id = C.manga_id + ON M.id = C.manga_id INNER JOIN history H ON C._id = H.chapter_id - WHERE M._id = :mangaId + WHERE M.id = :mangaId ); removeAllHistory: diff --git a/data/src/main/sqldelight/tachiyomi/data/manga.sq b/data/src/main/sqldelight/tachiyomi/data/manga.sq new file mode 100644 index 00000000000..1a08b10c986 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/manga.sq @@ -0,0 +1,262 @@ +import eu.kanade.tachiyomi.source.model.UpdateStrategy; +import kotlin.Boolean; +import kotlin.String; +import kotlin.collections.List; +import kotlinx.serialization.json.JsonObject; + +CREATE TABLE manga( + id INTEGER NOT NULL PRIMARY KEY, + source_id INTEGER NOT NULL, + remote_url TEXT NOT NULL, + remote_title TEXT NOT NULL, + remote_author TEXT, + remote_artist TEXT, + remote_description TEXT, + remote_genre TEXT AS List, + remote_status INTEGER NOT NULL, + remote_cover TEXT, + remote_update_strategy INTEGER AS UpdateStrategy NOT NULL, + remote_memo BLOB AS JsonObject NOT NULL, + user_favorite_at INTEGER, + user_notes TEXT NOT NULL, + user_reader_flags INTEGER NOT NULL, + user_chapter_flags INTEGER NOT NULL, + state_chapter_last_update INTEGER, + state_chapter_next_update INTEGER, + state_chapter_fetch_interval INTEGER NOT NULL, + state_cover_last_modified INTEGER NOT NULL, + state_initialized INTEGER AS Boolean NOT NULL, + state_modified_at INTEGER NOT NULL, + UNIQUE(source_id, remote_url) +); + +CREATE INDEX manga_user_favorite_at_index ON manga(user_favorite_at) WHERE user_favorite_at IS NOT NULL; + +CREATE TRIGGER update_modified_at_manga +AFTER UPDATE ON manga +FOR EACH ROW +BEGIN + UPDATE manga + SET state_modified_at = strftime('%s', 'now') + WHERE id = new.id; +END; + +getMangaById: +SELECT * +FROM manga +WHERE id = :id; + +-- TODO: this should ideally never really have more than 1 result +getMangaByUrlAndSource: +SELECT * +FROM manga +WHERE remote_url = :url +AND source_id = :source +LIMIT 1; + +getFavorites: +SELECT * +FROM manga +WHERE user_favorite_at IS NOT NULL; + +getReadMangaNotInLibrary: +SELECT * +FROM manga +WHERE user_favorite_at IS NULL AND id IN ( + SELECT DISTINCT chapters.manga_id + FROM chapters + WHERE read = 1 OR last_page_read != 0 +); + +getAllManga: +SELECT * +FROM manga; + +getAllMangaSourceAndUrl: +SELECT source_id, remote_url +FROM manga; + +getMangasWithFavoriteTimestamp: +SELECT * +FROM manga +WHERE user_favorite_at IS NOT NULL; + +getSourceIdWithFavoriteCount: +SELECT +source_id, +count(*) +FROM manga +WHERE user_favorite_at IS NOT NULL +GROUP BY source_id; + +getFavoriteBySourceId: +SELECT * +FROM manga +WHERE user_favorite_at IS NOT NULL +AND source_id = :sourceId; + +getDuplicateLibraryManga: +WITH +track_dupes AS ( + SELECT DISTINCT S2.manga_id + FROM manga_sync S1 + INNER JOIN manga_sync S2 + ON S1.sync_id = S2.sync_id + AND S1.remote_id = S2.remote_id + AND S1.manga_id != S2.manga_id + WHERE S1.manga_id = :id +), +duplicates AS ( + SELECT M.* + FROM manga M + LEFT JOIN track_dupes D + ON D.manga_id = id + WHERE user_favorite_at IS NOT NULL + AND id != :id + AND (lower(remote_title) LIKE '%' || lower(:title) || '%' OR D.manga_id IS NOT NULL) +), +chapter_counts AS ( + SELECT + M.id AS manga_id, + count(*) AS chapter_count + FROM duplicates M + JOIN chapters C + ON M.id = C.manga_id + LEFT JOIN excluded_scanlators ES + ON C.manga_id = ES.manga_id + AND C.scanlator = ES.scanlator + WHERE ES.scanlator IS NULL + GROUP BY M.id +) +SELECT + M.*, + coalesce(CC.chapter_count, 0) AS chapter_count +FROM duplicates M +LEFT JOIN chapter_counts CC +ON M.id = CC.manga_id; + +getUpcomingManga: +SELECT * +FROM manga +WHERE state_chapter_next_update >= :startOfDay +AND user_favorite_at IS NOT NULL +AND remote_status IN :statuses +AND ( + -- includedEmpty being true expresses "don't care" state and bypasses the membership filter + :includedEmpty + OR EXISTS ( + SELECT 1 FROM mangas_categories + WHERE mangas_categories.manga_id = manga.id + AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories + ) + OR (0 IN :includedCategories AND NOT EXISTS ( + SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = manga.id) + ) +) +AND ( + -- excludedEmpty being true expresses "don't care" state and bypasses the membership filter + :excludedEmpty + OR ( + NOT EXISTS ( + SELECT 1 FROM mangas_categories + WHERE mangas_categories.manga_id = manga.id + AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories + ) + AND ( + 0 NOT IN :excludedCategories + OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = manga.id) + ) + ) +) +ORDER BY state_chapter_next_update ASC; + +resetViewerFlags: +UPDATE manga +SET user_reader_flags = 0; + +getSourceIdsWithNonLibraryManga: +SELECT source_id, COUNT(*) AS manga_count +FROM manga +WHERE user_favorite_at IS NULL +GROUP BY source_id; + +deleteNonLibraryManga: +DELETE FROM manga +WHERE user_favorite_at IS NULL +AND source_id IN :sourceIds +AND ( + :keepReadManga = 0 + OR id NOT IN ( + SELECT DISTINCT manga_id + FROM chapters + WHERE read = 1 + OR last_page_read != 0 + ) +); + +insertReturningId: +INSERT INTO manga(source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title, remote_status, remote_cover, user_favorite_at, state_chapter_last_update, state_chapter_next_update, state_initialized, user_reader_flags, user_chapter_flags, state_cover_last_modified, remote_update_strategy, state_chapter_fetch_interval, state_modified_at, user_notes, remote_memo) +VALUES (:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favoriteAt, :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :updateStrategy, :calculateInterval, 0, :notes, :memo) +RETURNING id; + +update: +UPDATE manga SET + source_id = coalesce(:source, source_id), + remote_url = coalesce(:url, remote_url), + remote_artist = coalesce(:artist, remote_artist), + remote_author = coalesce(:author, remote_author), + remote_description = coalesce(:description, remote_description), + remote_genre = coalesce(:genre, remote_genre), + remote_title = coalesce(:title, remote_title), + remote_status = coalesce(:status, remote_status), + remote_cover = coalesce(:thumbnailUrl, remote_cover), + user_favorite_at = coalesce(:favoriteAt, user_favorite_at), + state_chapter_last_update = coalesce(:lastUpdate, state_chapter_last_update), + state_chapter_next_update = coalesce(:nextUpdate, state_chapter_next_update), + state_initialized = coalesce(:initialized, state_initialized), + user_reader_flags = coalesce(:viewer, user_reader_flags), + user_chapter_flags = coalesce(:chapterFlags, user_chapter_flags), + state_cover_last_modified = coalesce(:coverLastModified, state_cover_last_modified), + remote_update_strategy = coalesce(:updateStrategy, remote_update_strategy), + state_chapter_fetch_interval = coalesce(:calculateInterval, state_chapter_fetch_interval), + user_notes = coalesce(:notes, user_notes), + remote_memo = coalesce(:memo, remote_memo) +WHERE id = :mangaId; + +insertNetworkManga { + -- Insert the manga if it doesn't exist already + INSERT INTO manga( + source_id, remote_url, remote_artist, remote_author, remote_description, remote_genre, remote_title, remote_status, remote_cover, user_favorite_at, + state_chapter_last_update, state_chapter_next_update, state_initialized, user_reader_flags, user_chapter_flags, state_cover_last_modified, + remote_update_strategy, state_chapter_fetch_interval, state_modified_at, user_notes, remote_memo + ) + SELECT + :source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favoriteAt, + :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, + :updateStrategy, :calculateInterval, 0, '', :memo + WHERE NOT EXISTS(SELECT 0 FROM manga WHERE source_id = :source AND remote_url = :url); + + -- Update the relevant details if applicable and not favorite + UPDATE manga + SET + remote_title = CASE WHEN :updateTitle THEN :title ELSE remote_title END, + remote_cover = CASE WHEN :updateCover THEN :thumbnailUrl ELSE remote_cover END, + remote_author = CASE WHEN :updateDetails THEN :author ELSE remote_author END, + remote_artist = CASE WHEN :updateDetails THEN :artist ELSE remote_artist END, + remote_description = CASE WHEN :updateDetails THEN :description ELSE remote_description END, + remote_genre = CASE WHEN :updateDetails THEN :genre ELSE remote_genre END, + remote_status = CASE WHEN :updateDetails THEN :status ELSE remote_status END, + remote_update_strategy = CASE WHEN :updateDetails THEN :updateStrategy ELSE remote_update_strategy END, + state_initialized = :updateDetails, + remote_memo = :memo + WHERE source_id = :source + AND remote_url = :url + AND user_favorite_at IS NULL; + + -- Finally return the manga + SELECT * + FROM manga + WHERE source_id = :source + AND remote_url = :url + LIMIT 1; +} diff --git a/data/src/main/sqldelight/tachiyomi/data/manga_sync.sq b/data/src/main/sqldelight/tachiyomi/data/manga_sync.sq index f76b6bb7eb0..d3007fbb104 100644 --- a/data/src/main/sqldelight/tachiyomi/data/manga_sync.sq +++ b/data/src/main/sqldelight/tachiyomi/data/manga_sync.sq @@ -16,7 +16,7 @@ CREATE TABLE manga_sync( finish_date INTEGER NOT NULL, private INTEGER AS Boolean DEFAULT 0 NOT NULL, UNIQUE (manga_id, sync_id) ON CONFLICT REPLACE, - FOREIGN KEY(manga_id) REFERENCES mangas (_id) + FOREIGN KEY(manga_id) REFERENCES manga (id) ON DELETE CASCADE ); diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas.sq b/data/src/main/sqldelight/tachiyomi/data/mangas.sq deleted file mode 100644 index b5d1909f759..00000000000 --- a/data/src/main/sqldelight/tachiyomi/data/mangas.sq +++ /dev/null @@ -1,293 +0,0 @@ -import eu.kanade.tachiyomi.source.model.UpdateStrategy; -import kotlin.Boolean; -import kotlin.String; -import kotlin.collections.List; -import kotlinx.serialization.json.JsonObject; - -CREATE TABLE mangas( - _id INTEGER NOT NULL PRIMARY KEY, - source INTEGER NOT NULL, - url TEXT NOT NULL, - artist TEXT, - author TEXT, - description TEXT, - genre TEXT AS List, - title TEXT NOT NULL, - status INTEGER NOT NULL, - thumbnail_url TEXT, - favorite INTEGER AS Boolean NOT NULL, - last_update INTEGER, - next_update INTEGER, - initialized INTEGER AS Boolean NOT NULL, - viewer INTEGER NOT NULL, - chapter_flags INTEGER NOT NULL, - cover_last_modified INTEGER NOT NULL, - date_added INTEGER NOT NULL, - update_strategy INTEGER AS UpdateStrategy NOT NULL DEFAULT 0, - calculate_interval INTEGER DEFAULT 0 NOT NULL, - last_modified_at INTEGER NOT NULL DEFAULT 0, - favorite_modified_at INTEGER, - version INTEGER NOT NULL DEFAULT 0, - is_syncing INTEGER NOT NULL DEFAULT 0, - notes TEXT NOT NULL DEFAULT "", - memo BLOB AS JsonObject NOT NULL DEFAULT '{}' -); - -CREATE INDEX library_favorite_index ON mangas(favorite) WHERE favorite = 1; -CREATE INDEX mangas_url_index ON mangas(url); -CREATE INDEX idx_mangas_source ON mangas(source); - -CREATE TRIGGER update_last_favorited_at_mangas -AFTER UPDATE OF favorite ON mangas -BEGIN - UPDATE mangas - SET favorite_modified_at = strftime('%s', 'now') - WHERE _id = new._id; -END; - -CREATE TRIGGER update_last_modified_at_mangas -AFTER UPDATE ON mangas -FOR EACH ROW -BEGIN - UPDATE mangas - SET last_modified_at = strftime('%s', 'now') - WHERE _id = new._id; -END; - -CREATE TRIGGER update_manga_version AFTER UPDATE ON mangas -BEGIN - UPDATE mangas SET version = version + 1 - WHERE _id = new._id AND new.is_syncing = 0 AND ( - new.url != old.url OR - new.description != old.description OR - new.favorite != old.favorite - ); -END; - -getMangaById: -SELECT * -FROM mangas -WHERE _id = :id; - --- TODO: this should ideally never really have more than 1 result -getMangaByUrlAndSource: -SELECT * -FROM mangas -WHERE url = :url -AND source = :source -LIMIT 1; - -getFavorites: -SELECT * -FROM mangas -WHERE favorite = 1; - -getReadMangaNotInLibrary: -SELECT * -FROM mangas -WHERE favorite = 0 AND _id IN ( - SELECT DISTINCT chapters.manga_id - FROM chapters - WHERE read = 1 OR last_page_read != 0 -); - -getAllManga: -SELECT * -FROM mangas; - -getAllMangaSourceAndUrl: -SELECT source, url -FROM mangas; - -getMangasWithFavoriteTimestamp: -SELECT * -FROM mangas -WHERE favorite_modified_at IS NOT NULL; - -getSourceIdWithFavoriteCount: -SELECT -source, -count(*) -FROM mangas -WHERE favorite = 1 -GROUP BY source; - -getFavoriteBySourceId: -SELECT * -FROM mangas -WHERE favorite = 1 -AND source = :sourceId; - -getDuplicateLibraryManga: -WITH -track_dupes AS ( - SELECT DISTINCT S2.manga_id - FROM manga_sync S1 - INNER JOIN manga_sync S2 - ON S1.sync_id = S2.sync_id - AND S1.remote_id = S2.remote_id - AND S1.manga_id != S2.manga_id - WHERE S1.manga_id = :id -), -duplicates AS ( - SELECT M.* - FROM mangas M - LEFT JOIN track_dupes D - ON D.manga_id = _id - WHERE favorite = 1 - AND _id != :id - AND (lower(title) LIKE '%' || lower(:title) || '%' OR D.manga_id IS NOT NULL) -), -chapter_counts AS ( - SELECT - M._id AS manga_id, - count(*) AS chapter_count - FROM duplicates M - JOIN chapters C - ON M._id = C.manga_id - LEFT JOIN excluded_scanlators ES - ON C.manga_id = ES.manga_id - AND C.scanlator = ES.scanlator - WHERE ES.scanlator IS NULL - GROUP BY M._id -) -SELECT - M.*, - coalesce(CC.chapter_count, 0) AS chapter_count -FROM duplicates M -LEFT JOIN chapter_counts CC -ON M._id = CC.manga_id; - -getUpcomingManga: -SELECT * -FROM mangas -WHERE next_update >= :startOfDay -AND favorite = 1 -AND status IN :statuses -AND ( - -- includedEmpty being true expresses "don't care" state and bypasses the membership filter - :includedEmpty - OR EXISTS ( - SELECT 1 FROM mangas_categories - WHERE mangas_categories.manga_id = mangas._id - AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories - ) - OR (0 IN :includedCategories AND NOT EXISTS ( - SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangas._id) - ) -) -AND ( - -- excludedEmpty being true expresses "don't care" state and bypasses the membership filter - :excludedEmpty - OR ( - NOT EXISTS ( - SELECT 1 FROM mangas_categories - WHERE mangas_categories.manga_id = mangas._id - AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories - ) - AND ( - 0 NOT IN :excludedCategories - OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangas._id) - ) - ) -) -ORDER BY next_update ASC; - -resetViewerFlags: -UPDATE mangas -SET viewer = 0; - -resetIsSyncing: -UPDATE mangas -SET is_syncing = 0 -WHERE is_syncing = 1; - -getSourceIdsWithNonLibraryManga: -SELECT source, COUNT(*) AS manga_count -FROM mangas -WHERE favorite = 0 -GROUP BY source; - -deleteNonLibraryManga: -DELETE FROM mangas -WHERE favorite = 0 -AND source IN :sourceIds -AND ( - :keepReadManga = 0 - OR _id NOT IN ( - SELECT DISTINCT manga_id - FROM chapters - WHERE read = 1 - OR last_page_read != 0 - ) -); - -insertReturningId: -INSERT INTO mangas(source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, date_added, update_strategy, calculate_interval, last_modified_at, version, notes, memo) -VALUES (:source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favorite, :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :dateAdded, :updateStrategy, :calculateInterval, 0, :version, :notes, :memo) -RETURNING _id; - -update: -UPDATE mangas SET - source = coalesce(:source, source), - url = coalesce(:url, url), - artist = coalesce(:artist, artist), - author = coalesce(:author, author), - description = coalesce(:description, description), - genre = coalesce(:genre, genre), - title = coalesce(:title, title), - status = coalesce(:status, status), - thumbnail_url = coalesce(:thumbnailUrl, thumbnail_url), - favorite = coalesce(:favorite, favorite), - last_update = coalesce(:lastUpdate, last_update), - next_update = coalesce(:nextUpdate, next_update), - initialized = coalesce(:initialized, initialized), - viewer = coalesce(:viewer, viewer), - chapter_flags = coalesce(:chapterFlags, chapter_flags), - cover_last_modified = coalesce(:coverLastModified, cover_last_modified), - date_added = coalesce(:dateAdded, date_added), - update_strategy = coalesce(:updateStrategy, update_strategy), - calculate_interval = coalesce(:calculateInterval, calculate_interval), - version = coalesce(:version, version), - is_syncing = coalesce(:isSyncing, is_syncing), - notes = coalesce(:notes, notes), - memo = coalesce(:memo, memo) -WHERE _id = :mangaId; - -insertNetworkManga { - -- Insert the manga if it doesn't exist already - INSERT INTO mangas( - source, url, artist, author, description, genre, title, status, thumbnail_url, favorite, - last_update, next_update, initialized, viewer, chapter_flags, cover_last_modified, date_added, - update_strategy, calculate_interval, last_modified_at, version, memo - ) - SELECT - :source, :url, :artist, :author, :description, :genre, :title, :status, :thumbnailUrl, :favorite, - :lastUpdate, :nextUpdate, :initialized, :viewerFlags, :chapterFlags, :coverLastModified, :dateAdded, - :updateStrategy, :calculateInterval, 0, :version, :memo - WHERE NOT EXISTS(SELECT 0 FROM mangas WHERE source = :source AND url = :url); - - -- Update the relevant details if applicable and not favorite - UPDATE mangas - SET - title = CASE WHEN :updateTitle THEN :title ELSE title END, - thumbnail_url = CASE WHEN :updateCover THEN :thumbnailUrl ELSE thumbnail_url END, - author = CASE WHEN :updateDetails THEN :author ELSE author END, - artist = CASE WHEN :updateDetails THEN :artist ELSE artist END, - description = CASE WHEN :updateDetails THEN :description ELSE description END, - genre = CASE WHEN :updateDetails THEN :genre ELSE genre END, - status = CASE WHEN :updateDetails THEN :status ELSE status END, - update_strategy = CASE WHEN :updateDetails THEN :updateStrategy ELSE update_strategy END, - initialized = :updateDetails, - memo = :memo - WHERE source = :source - AND url = :url - AND favorite = 0; - - -- Finally return the manga - SELECT * - FROM mangas - WHERE source = :source - AND url = :url - LIMIT 1; -} diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq b/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq index 7500d797ab9..bf386915912 100644 --- a/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq +++ b/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq @@ -4,20 +4,13 @@ CREATE TABLE mangas_categories( category_id INTEGER NOT NULL, FOREIGN KEY(category_id) REFERENCES categories (_id) ON DELETE CASCADE, - FOREIGN KEY(manga_id) REFERENCES mangas (_id) + FOREIGN KEY(manga_id) REFERENCES manga (id) ON DELETE CASCADE ); CREATE INDEX idx_mangas_categories_manga_id ON mangas_categories(manga_id); CREATE INDEX idx_mangas_categories_category_id ON mangas_categories(category_id); -CREATE TRIGGER insert_manga_category_update_version AFTER INSERT ON mangas_categories -BEGIN - UPDATE mangas - SET version = version + 1 - WHERE _id = new.manga_id AND (SELECT is_syncing FROM mangas WHERE _id = new.manga_id) = 0; -END; - insert: INSERT INTO mangas_categories(manga_id, category_id) VALUES (:mangaId, :categoryId); diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm new file mode 100644 index 00000000000..16f58ddbfaf --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -0,0 +1,234 @@ +import eu.kanade.tachiyomi.source.model.UpdateStrategy; +import kotlin.Boolean; +import kotlin.String; +import kotlin.collections.List; +import kotlinx.serialization.json.JsonObject; + +-- --------------------------------------------------------------------------------------------------------- +-- 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; + +-- The sync scaffolding these maintain (version, is_syncing, favorite_modified_at) is gone +DROP TRIGGER update_last_favorited_at_mangas; +DROP TRIGGER update_last_modified_at_mangas; +DROP TRIGGER update_manga_version; +DROP TRIGGER update_chapter_and_manga_version; +DROP TRIGGER insert_manga_category_update_version; + +-- Entries sharing a source and url are merged into one, since the new table won't hold both. The one that +-- stays is a favorite if there is any, the oldest otherwise, and it takes over the details that it is +-- missing along with everything attached to the entries it absorbs. +CREATE TEMP TABLE duplicate_mangas AS +SELECT + _id, + first_value(_id) OVER (PARTITION BY source, url ORDER BY favorite DESC, _id ASC) AS keep_id +FROM mangas +WHERE (source, url) IN ( + SELECT source, url + FROM mangas + GROUP BY source, url + HAVING count(*) > 1 +); + +UPDATE mangas +SET favorite = merged.favorite, + initialized = merged.initialized, + artist = coalesce(mangas.artist, merged.artist), + author = coalesce(mangas.author, merged.author), + description = coalesce(mangas.description, merged.description), + genre = coalesce(mangas.genre, merged.genre), + thumbnail_url = coalesce(mangas.thumbnail_url, merged.thumbnail_url), + status = coalesce(nullif(mangas.status, 0), merged.status, 0), + viewer = coalesce(nullif(mangas.viewer, 0), merged.viewer, 0), + chapter_flags = coalesce(nullif(mangas.chapter_flags, 0), merged.chapter_flags, 0), + notes = coalesce(nullif(mangas.notes, ''), merged.notes, ''), + memo = coalesce(nullif(mangas.memo, '{}'), merged.memo, '{}'), + last_update = merged.last_update, + date_added = coalesce(merged.date_added, mangas.date_added), + favorite_modified_at = merged.favorite_modified_at, + cover_last_modified = merged.cover_last_modified +FROM ( + SELECT + D.keep_id, + max(M.favorite) AS favorite, + max(M.initialized) AS initialized, + max(M.artist) AS artist, + max(M.author) AS author, + max(M.description) AS description, + max(M.genre) AS genre, + max(M.thumbnail_url) AS thumbnail_url, + max(nullif(M.status, 0)) AS status, + max(nullif(M.viewer, 0)) AS viewer, + max(nullif(M.chapter_flags, 0)) AS chapter_flags, + max(nullif(M.notes, '')) AS notes, + max(nullif(M.memo, '{}')) AS memo, + max(M.last_update) AS last_update, + min(nullif(M.date_added, 0)) AS date_added, + min(M.favorite_modified_at) AS favorite_modified_at, + max(M.cover_last_modified) AS cover_last_modified + FROM duplicate_mangas D + JOIN mangas M + ON M._id = D._id + GROUP BY D.keep_id +) AS merged +WHERE mangas._id = merged.keep_id; + +-- Every child table is rebuilt further down and resolves the surviving entry there, so nothing +-- else has to be rewritten here first. +DELETE FROM mangas +WHERE _id IN (SELECT _id FROM duplicate_mangas WHERE _id != keep_id); + +CREATE TABLE manga( + id INTEGER NOT NULL PRIMARY KEY, + source_id INTEGER NOT NULL, + remote_url TEXT NOT NULL, + remote_title TEXT NOT NULL, + remote_author TEXT, + remote_artist TEXT, + remote_description TEXT, + remote_genre TEXT AS List, + remote_status INTEGER NOT NULL, + remote_cover TEXT, + remote_update_strategy INTEGER AS UpdateStrategy NOT NULL, + remote_memo BLOB AS JsonObject NOT NULL, + user_favorite_at INTEGER, + user_notes TEXT NOT NULL, + user_reader_flags INTEGER NOT NULL, + user_chapter_flags INTEGER NOT NULL, + state_chapter_last_update INTEGER, + state_chapter_next_update INTEGER, + state_chapter_fetch_interval INTEGER NOT NULL, + state_cover_last_modified INTEGER NOT NULL, + state_initialized INTEGER AS Boolean NOT NULL, + state_modified_at INTEGER NOT NULL, + UNIQUE(source_id, remote_url) +); + +-- `favorite`, `date_added` and `favorite_modified_at` all said the same thing: an entry is in the library +-- exactly when it has a timestamp, and that timestamp is when it was added. `date_added` is the +-- authoritative one, with the favorite timestamp covering entries added before it was written. That one +-- was written by a trigger in seconds, unlike every other timestamp here, so it is scaled up. +INSERT INTO manga( + id, source_id, remote_url, remote_title, remote_author, remote_artist, remote_description, remote_genre, + remote_status, remote_cover, remote_update_strategy, remote_memo, user_favorite_at, user_notes, + user_reader_flags, user_chapter_flags, state_chapter_last_update, state_chapter_next_update, state_chapter_fetch_interval, + state_cover_last_modified, state_initialized, state_modified_at +) +SELECT + _id, source, url, title, author, artist, description, genre, + status, thumbnail_url, update_strategy, memo, + CASE WHEN favorite = 1 THEN coalesce(nullif(date_added, 0), favorite_modified_at * 1000, 0) END, notes, + viewer, chapter_flags, last_update, next_update, calculate_interval, + cover_last_modified, initialized, last_modified_at +FROM mangas; + +DROP TABLE mangas; + +CREATE INDEX manga_user_favorite_at_index ON manga(user_favorite_at) WHERE user_favorite_at IS NOT NULL; + +CREATE TRIGGER update_modified_at_manga +AFTER UPDATE ON manga +FOR EACH ROW +BEGIN + UPDATE manga + SET state_modified_at = strftime('%s', 'now') + WHERE id = new.id; +END; +-- The views read the columns that just changed, so they come back in their new shape +CREATE VIEW historyView AS +SELECT + history._id AS id, + manga.id AS mangaId, + chapters._id AS chapterId, + manga.remote_title AS title, + manga.remote_cover AS thumbnailUrl, + manga.source_id AS source, + manga.user_favorite_at AS favoriteAt, + manga.state_cover_last_modified, + chapters.chapter_number AS chapterNumber, + history.last_read AS readAt, + history.time_read AS readDuration, + max_last_read.last_read AS maxReadAt, + max_last_read.chapter_id AS maxReadAtChapterId +FROM manga +JOIN chapters +ON manga.id = chapters.manga_id +JOIN history +ON chapters._id = history.chapter_id +JOIN ( + SELECT chapters.manga_id,chapters._id AS chapter_id, MAX(history.last_read) AS last_read + FROM chapters JOIN history + ON chapters._id = history.chapter_id + GROUP BY chapters.manga_id +) AS max_last_read +ON chapters.manga_id = max_last_read.manga_id; + +CREATE VIEW libraryView AS +SELECT + M.*, + coalesce(C.total, 0) AS totalCount, + coalesce(C.readCount, 0) AS readCount, + coalesce(C.latestUpload, 0) AS latestUpload, + coalesce(C.fetchedAt, 0) AS chapterFetchedAt, + coalesce(C.lastRead, 0) AS lastRead, + coalesce(C.bookmarkCount, 0) AS bookmarkCount, + coalesce(MC.categories, '0') AS categories +FROM manga M +LEFT JOIN ( + SELECT + chapters.manga_id, + count(*) AS total, + sum(read) AS readCount, + coalesce(max(chapters.date_upload), 0) AS latestUpload, + coalesce(max(history.last_read), 0) AS lastRead, + coalesce(max(chapters.date_fetch), 0) AS fetchedAt, + sum(chapters.bookmark) AS bookmarkCount + FROM chapters + LEFT JOIN excluded_scanlators + ON chapters.manga_id = excluded_scanlators.manga_id + AND chapters.scanlator = excluded_scanlators.scanlator + LEFT JOIN history + ON chapters._id = history.chapter_id + WHERE excluded_scanlators.scanlator IS NULL + GROUP BY chapters.manga_id +) AS C +ON M.id = C.manga_id +LEFT JOIN ( + SELECT manga_id, group_concat(category_id) AS categories + FROM mangas_categories + GROUP BY manga_id +) AS MC +ON MC.manga_id = M.id +WHERE M.user_favorite_at IS NOT NULL; + +CREATE VIEW updatesView AS +SELECT + manga.id AS mangaId, + manga.remote_title AS mangaTitle, + chapters._id AS chapterId, + chapters.name AS chapterName, + chapters.scanlator, + chapters.url AS chapterUrl, + chapters.read, + chapters.bookmark, + chapters.last_page_read, + manga.source_id AS source, + manga.user_favorite_at AS favoriteAt, + manga.remote_cover AS thumbnailUrl, + manga.state_cover_last_modified AS coverLastModified, + chapters.date_upload AS dateUpload, + chapters.date_fetch AS datefetch, + excluded_scanlators.scanlator AS excludedScanlator +FROM manga JOIN chapters +ON manga.id = chapters.manga_id +LEFT JOIN excluded_scanlators +ON manga.id = excluded_scanlators.manga_id +AND chapters.scanlator = excluded_scanlators.scanlator +WHERE user_favorite_at IS NOT NULL +AND date_fetch > user_favorite_at +ORDER BY date_fetch DESC; diff --git a/data/src/main/sqldelight/tachiyomi/view/historyView.sq b/data/src/main/sqldelight/tachiyomi/view/historyView.sq index bdaeb52594e..632e74f5b08 100644 --- a/data/src/main/sqldelight/tachiyomi/view/historyView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/historyView.sq @@ -1,21 +1,21 @@ CREATE VIEW historyView AS SELECT history._id AS id, - mangas._id AS mangaId, + manga.id AS mangaId, chapters._id AS chapterId, - mangas.title, - mangas.thumbnail_url AS thumbnailUrl, - mangas.source, - mangas.favorite, - mangas.cover_last_modified, + manga.remote_title AS title, + manga.remote_cover AS thumbnailUrl, + manga.source_id AS source, + manga.user_favorite_at AS favoriteAt, + manga.state_cover_last_modified, chapters.chapter_number AS chapterNumber, history.last_read AS readAt, history.time_read AS readDuration, max_last_read.last_read AS maxReadAt, max_last_read.chapter_id AS maxReadAtChapterId -FROM mangas +FROM manga JOIN chapters -ON mangas._id = chapters.manga_id +ON manga.id = chapters.manga_id JOIN history ON chapters._id = history.chapter_id JOIN ( @@ -34,8 +34,8 @@ chapterId, title, thumbnailUrl, source, -favorite, -cover_last_modified, +favoriteAt, +state_cover_last_modified, chapterNumber, readAt, readDuration @@ -53,8 +53,8 @@ chapterId, title, thumbnailUrl, source, -favorite, -cover_last_modified, +favoriteAt, +state_cover_last_modified, chapterNumber, readAt, readDuration diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index 8ce19ae1de7..3d4a2c07b99 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -8,7 +8,7 @@ SELECT coalesce(C.lastRead, 0) AS lastRead, coalesce(C.bookmarkCount, 0) AS bookmarkCount, coalesce(MC.categories, '0') AS categories -FROM mangas M +FROM manga M LEFT JOIN ( SELECT chapters.manga_id, @@ -27,14 +27,14 @@ LEFT JOIN ( WHERE excluded_scanlators.scanlator IS NULL GROUP BY chapters.manga_id ) AS C -ON M._id = C.manga_id +ON M.id = C.manga_id LEFT JOIN ( SELECT manga_id, group_concat(category_id) AS categories FROM mangas_categories GROUP BY manga_id ) AS MC -ON MC.manga_id = M._id -WHERE M.favorite = 1; +ON MC.manga_id = M.id +WHERE M.user_favorite_at IS NOT NULL; library: SELECT * diff --git a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq index 5995b1bedb1..c95a4867334 100644 --- a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq @@ -1,7 +1,7 @@ CREATE VIEW updatesView AS SELECT - mangas._id AS mangaId, - mangas.title AS mangaTitle, + manga.id AS mangaId, + manga.remote_title AS mangaTitle, chapters._id AS chapterId, chapters.name AS chapterName, chapters.scanlator, @@ -9,20 +9,20 @@ SELECT chapters.read, chapters.bookmark, chapters.last_page_read, - mangas.source, - mangas.favorite, - mangas.thumbnail_url AS thumbnailUrl, - mangas.cover_last_modified AS coverLastModified, + manga.source_id AS source, + manga.user_favorite_at AS favoriteAt, + manga.remote_cover AS thumbnailUrl, + manga.state_cover_last_modified AS coverLastModified, chapters.date_upload AS dateUpload, chapters.date_fetch AS datefetch, excluded_scanlators.scanlator AS excludedScanlator -FROM mangas JOIN chapters -ON mangas._id = chapters.manga_id +FROM manga JOIN chapters +ON manga.id = chapters.manga_id LEFT JOIN excluded_scanlators -ON mangas._id = excluded_scanlators.manga_id +ON manga.id = excluded_scanlators.manga_id AND chapters.scanlator = excluded_scanlators.scanlator -WHERE favorite = 1 -AND date_fetch > date_added +WHERE user_favorite_at IS NOT NULL +AND date_fetch > user_favorite_at ORDER BY date_fetch DESC; getRecentUpdates: From 569544a947b20b1a997365ddd36e81f8ea4d174d Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:03:19 +0600 Subject: [PATCH 02/11] table - chapter Rebuild `chapters` as `chapter` with a UNIQUE(manga_id, remote_url) constraint, `remote_`/`user_`/`state_` column prefixes and `_id` -> `id`. The entries that merged in the previous step brought their chapters with them, so the chapters move onto the surviving entry here and the duplicates that creates are merged: the copy with reading history is kept, the oldest otherwise, and the read state and history of the others are folded into it. It happens here rather than with the merge because this is where the constraint that rejects the duplicates is introduced. Drop the sync scaffolding (`version`, `is_syncing`). `chapters_manga_id_index`, `chapters_unread_by_manga_index` and `idx_chapters_url` all go away: the new unique constraint indexes `manga_id`, no query filters on unread chapters, and the only url-only lookup has no callers. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../create/creators/MangaBackupCreator.kt | 6 +- .../data/backup/models/BackupChapter.kt | 16 +- .../backup/restore/restorers/MangaRestorer.kt | 11 +- app/src/main/java/mihon/app/di/AppBindings.kt | 6 +- .../data/chapter/ChapterRepositoryImpl.kt | 39 ++-- .../main/sqldelight/tachiyomi/data/chapter.sq | 100 +++++++++ .../sqldelight/tachiyomi/data/chapters.sq | 112 ----------- .../main/sqldelight/tachiyomi/data/history.sq | 18 +- .../main/sqldelight/tachiyomi/data/manga.sq | 16 +- .../sqldelight/tachiyomi/migrations/14.sqm | 190 ++++++++++++++---- .../sqldelight/tachiyomi/view/historyView.sq | 20 +- .../sqldelight/tachiyomi/view/libraryView.sq | 20 +- .../sqldelight/tachiyomi/view/updatesView.sq | 28 +-- 13 files changed, 341 insertions(+), 241 deletions(-) create mode 100644 data/src/main/sqldelight/tachiyomi/data/chapter.sq delete mode 100644 data/src/main/sqldelight/tachiyomi/data/chapters.sq diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index aebb47a6e1a..cf25d19f84c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -39,7 +39,7 @@ class MangaBackupCreator( if (options.chapters) { // Backup all the chapters - database.chaptersQueries + database.chapterQueries .getChaptersByMangaId( mangaId = manga.id, applyScanlatorFilter = 0, // false @@ -71,10 +71,10 @@ class MangaBackupCreator( val historyByMangaId = getHistory.await(manga.id) if (historyByMangaId.isNotEmpty()) { val history = historyByMangaId.map { history -> - val chapter = database.chaptersQueries + val chapter = database.chapterQueries .getChapterById(history.chapterId) .awaitAsOne() - BackupHistory(chapter.url, history.readAt?.time ?: 0L, history.readDuration) + BackupHistory(chapter.remote_url, history.readAt?.time ?: 0L, history.readDuration) } if (history.isNotEmpty()) { mangaObject.history = history diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt index e1828a3020a..d4d591a5eb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupChapter.kt @@ -52,17 +52,15 @@ val backupChapterMapper = { url: String, name: String, scanlator: String?, + chapterNumber: Double, + dateUpload: Long, + sourceOrder: Long, + memo: JsonObject, read: Boolean, bookmark: Boolean, lastPageRead: Long, - chapterNumber: Double, - sourceOrder: Long, dateFetch: Long, - dateUpload: Long, - lastModifiedAt: Long, - version: Long, - _: Long, - memo: JsonObject, + modifiedAt: Long, -> BackupChapter( url = url, @@ -75,8 +73,8 @@ val backupChapterMapper = { dateFetch = dateFetch, dateUpload = dateUpload, sourceOrder = sourceOrder, - lastModifiedAt = lastModifiedAt, - version = version, + lastModifiedAt = modifiedAt, + version = 0, memo = MemoColumnAdapter.encode(memo), ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 8054964736d..a9c801dbfe9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -191,7 +191,7 @@ class MangaRestorer( private suspend fun insertNewChapters(chapters: List) { database.transaction { chapters.forEach { chapter -> - database.chaptersQueries.insert( + database.chapterQueries.insert( chapter.mangaId, chapter.url, chapter.name, @@ -203,7 +203,6 @@ class MangaRestorer( chapter.sourceOrder, chapter.dateFetch, chapter.dateUpload, - chapter.version, chapter.memo, ) } @@ -213,7 +212,7 @@ class MangaRestorer( private suspend fun updateExistingChapters(chapters: List) { database.transaction { chapters.forEach { chapter -> - database.chaptersQueries.update( + database.chapterQueries.update( mangaId = null, url = null, name = null, @@ -226,8 +225,6 @@ class MangaRestorer( dateFetch = null, dateUpload = null, chapterId = chapter.id, - version = chapter.version, - isSyncing = 0, memo = chapter.memo.let(MemoColumnAdapter::encode), ) } @@ -325,7 +322,7 @@ class MangaRestorer( val item = history.getHistoryImpl() if (dbHistory == null) { - val chapter = database.chaptersQueries + val chapter = database.chapterQueries .getChapterByUrlAndMangaId(history.url, manga.id) .awaitAsOneOrNull() return@mapNotNull if (chapter == null) { @@ -333,7 +330,7 @@ class MangaRestorer( null } else { // New history entry - item.copy(chapterId = chapter._id) + item.copy(chapterId = chapter.id) } } diff --git a/app/src/main/java/mihon/app/di/AppBindings.kt b/app/src/main/java/mihon/app/di/AppBindings.kt index 91951215d9a..0d065aec462 100644 --- a/app/src/main/java/mihon/app/di/AppBindings.kt +++ b/app/src/main/java/mihon/app/di/AppBindings.kt @@ -16,7 +16,7 @@ import kotlinx.serialization.protobuf.ProtoBuf import nl.adaptivity.xmlutil.XmlDeclMode import nl.adaptivity.xmlutil.core.XmlVersion import nl.adaptivity.xmlutil.serialization.XML -import tachiyomi.data.Chapters +import tachiyomi.data.Chapter import tachiyomi.data.Database import tachiyomi.data.DateColumnAdapter import tachiyomi.data.History @@ -54,8 +54,8 @@ object AppBindings { remote_update_strategyAdapter = UpdateStrategyColumnAdapter, remote_memoAdapter = MemoColumnAdapter, ), - chaptersAdapter = Chapters.Adapter( - memoAdapter = MemoColumnAdapter, + chapterAdapter = Chapter.Adapter( + remote_memoAdapter = MemoColumnAdapter, ), ) } diff --git a/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt index 9ee0baec81d..34e732539f9 100644 --- a/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/chapter/ChapterRepositoryImpl.kt @@ -30,7 +30,7 @@ class ChapterRepositoryImpl( return try { database.transactionWithResult { chapters.map { chapter -> - val chapterId = database.chaptersQueries.insertReturningId( + val chapterId = database.chapterQueries.insertReturningId( chapter.mangaId, chapter.url, chapter.name, @@ -42,7 +42,6 @@ class ChapterRepositoryImpl( chapter.sourceOrder, chapter.dateFetch, chapter.dateUpload, - chapter.version, chapter.memo, ) .awaitAsOne() @@ -66,7 +65,7 @@ class ChapterRepositoryImpl( private suspend fun partialUpdate(vararg chapterUpdates: ChapterUpdate) { database.transaction { chapterUpdates.forEach { chapterUpdate -> - database.chaptersQueries.update( + database.chapterQueries.update( mangaId = chapterUpdate.mangaId, url = chapterUpdate.url, name = chapterUpdate.name, @@ -79,8 +78,6 @@ class ChapterRepositoryImpl( dateFetch = chapterUpdate.dateFetch, dateUpload = chapterUpdate.dateUpload, chapterId = chapterUpdate.id, - version = chapterUpdate.version, - isSyncing = 0, memo = chapterUpdate.memo?.let(MemoColumnAdapter::encode), ) } @@ -89,50 +86,50 @@ class ChapterRepositoryImpl( override suspend fun removeChaptersWithIds(chapterIds: List) { try { - database.chaptersQueries.removeChaptersWithIds(chapterIds) + database.chapterQueries.removeChaptersWithIds(chapterIds) } catch (e: Exception) { logcat(LogPriority.ERROR, e) } } override suspend fun getChapterByMangaId(mangaId: Long, applyScanlatorFilter: Boolean): List { - return database.chaptersQueries + return database.chapterQueries .getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter) .awaitAsList() } override suspend fun getScanlatorsByMangaId(mangaId: Long): List { - return database.chaptersQueries + return database.chapterQueries .getScanlatorsByMangaId(mangaId) { it.orEmpty() } .awaitAsList() } override fun getScanlatorsByMangaIdAsFlow(mangaId: Long): Flow> { - return database.chaptersQueries + return database.chapterQueries .getScanlatorsByMangaId(mangaId) { it.orEmpty() } .subscribeToList() } override suspend fun getBookmarkedChaptersByMangaId(mangaId: Long): List { - return database.chaptersQueries + return database.chapterQueries .getBookmarkedChaptersByMangaId(mangaId, ::mapChapter) .awaitAsList() } override suspend fun getChapterById(id: Long): Chapter? { - return database.chaptersQueries + return database.chapterQueries .getChapterById(id, ::mapChapter) .awaitAsOneOrNull() } override suspend fun getChapterByMangaIdAsFlow(mangaId: Long, applyScanlatorFilter: Boolean): Flow> { - return database.chaptersQueries + return database.chapterQueries .getChaptersByMangaId(mangaId, applyScanlatorFilter.toLong(), ::mapChapter) .subscribeToList() } override suspend fun getChapterByUrlAndMangaId(url: String, mangaId: Long): Chapter? { - return database.chaptersQueries + return database.chapterQueries .getChapterByUrlAndMangaId(url, mangaId, ::mapChapter) .awaitAsOneOrNull() } @@ -144,17 +141,15 @@ class ChapterRepositoryImpl( url: String, name: String, scanlator: String?, + chapterNumber: Double, + dateUpload: Long, + sourceOrder: Long, + memo: JsonObject, read: Boolean, bookmark: Boolean, lastPageRead: Long, - chapterNumber: Double, - sourceOrder: Long, dateFetch: Long, - dateUpload: Long, - lastModifiedAt: Long, - version: Long, - isSyncing: Long, - memo: JsonObject, + modifiedAt: Long, ): Chapter = Chapter( id = id, mangaId = mangaId, @@ -168,8 +163,8 @@ class ChapterRepositoryImpl( dateUpload = dateUpload, chapterNumber = chapterNumber, scanlator = scanlator, - lastModifiedAt = lastModifiedAt, - version = version, + lastModifiedAt = modifiedAt, + version = 0, memo = memo, ) } diff --git a/data/src/main/sqldelight/tachiyomi/data/chapter.sq b/data/src/main/sqldelight/tachiyomi/data/chapter.sq new file mode 100644 index 00000000000..e28ff5c6cdc --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/chapter.sq @@ -0,0 +1,100 @@ +import kotlin.Boolean; +import kotlinx.serialization.json.JsonObject; + +CREATE TABLE chapter( + id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER NOT NULL, + remote_url TEXT NOT NULL, + remote_name TEXT NOT NULL, + remote_scanlator TEXT, + remote_chapter_number REAL NOT NULL, + remote_date_upload INTEGER NOT NULL, + remote_order INTEGER NOT NULL, + remote_memo BLOB AS JsonObject NOT NULL, + user_read INTEGER AS Boolean NOT NULL, + user_bookmark INTEGER AS Boolean NOT NULL, + user_last_page_read INTEGER NOT NULL, + state_date_fetch INTEGER NOT NULL, + state_modified_at INTEGER NOT NULL, + UNIQUE(manga_id, remote_url), + FOREIGN KEY(manga_id) REFERENCES manga (id) + ON DELETE CASCADE +); + +CREATE TRIGGER update_modified_at_chapter +AFTER UPDATE ON chapter +FOR EACH ROW +BEGIN + UPDATE chapter + SET state_modified_at = strftime('%s', 'now') + WHERE id = new.id; +END; + +getChapterById: +SELECT * +FROM chapter +WHERE id = :id; + +getChaptersByMangaId: +SELECT C.* +FROM chapter C +LEFT JOIN excluded_scanlators ES +ON C.manga_id = ES.manga_id +AND C.remote_scanlator = ES.scanlator +WHERE C.manga_id = :mangaId +AND ( + :applyScanlatorFilter = 0 + OR ES.scanlator IS NULL +); + +getScanlatorsByMangaId: +SELECT remote_scanlator +FROM chapter +WHERE manga_id = :mangaId; + +getBookmarkedChaptersByMangaId: +SELECT * +FROM chapter +WHERE user_bookmark +AND manga_id = :mangaId; + +getChapterByUrl: +SELECT * +FROM chapter +WHERE remote_url = :chapterUrl; + +getChapterByUrlAndMangaId: +SELECT * +FROM chapter +WHERE remote_url = :chapterUrl +AND manga_id = :mangaId +LIMIT 1; + +removeChaptersWithIds: +DELETE FROM chapter +WHERE id IN :chapterIds; + +insert: +INSERT INTO chapter(manga_id, remote_url, remote_name, remote_scanlator, user_read, user_bookmark, user_last_page_read, remote_chapter_number, remote_order, state_date_fetch, remote_date_upload, state_modified_at, remote_memo) +VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, 0, :memo); + +insertReturningId: +INSERT INTO chapter(manga_id, remote_url, remote_name, remote_scanlator, user_read, user_bookmark, user_last_page_read, remote_chapter_number, remote_order, state_date_fetch, remote_date_upload, state_modified_at, remote_memo) +VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, 0, :memo) +RETURNING id; + +update: +UPDATE chapter +SET manga_id = coalesce(:mangaId, manga_id), + remote_url = coalesce(:url, remote_url), + remote_name = coalesce(:name, remote_name), + remote_scanlator = coalesce(:scanlator, remote_scanlator), + user_read = coalesce(:read, user_read), + user_bookmark = coalesce(:bookmark, user_bookmark), + user_last_page_read = coalesce(:lastPageRead, user_last_page_read), + remote_chapter_number = coalesce(:chapterNumber, remote_chapter_number), + remote_order = coalesce(:sourceOrder, remote_order), + state_date_fetch = coalesce(:dateFetch, state_date_fetch), + remote_date_upload = coalesce(:dateUpload, remote_date_upload), + remote_memo = coalesce(:memo, remote_memo) +WHERE id = :chapterId; diff --git a/data/src/main/sqldelight/tachiyomi/data/chapters.sq b/data/src/main/sqldelight/tachiyomi/data/chapters.sq deleted file mode 100644 index 9b03308e211..00000000000 --- a/data/src/main/sqldelight/tachiyomi/data/chapters.sq +++ /dev/null @@ -1,112 +0,0 @@ -import kotlin.Boolean; -import kotlinx.serialization.json.JsonObject; - -CREATE TABLE chapters( - _id INTEGER NOT NULL PRIMARY KEY, - manga_id INTEGER NOT NULL, - url TEXT NOT NULL, - name TEXT NOT NULL, - scanlator TEXT, - read INTEGER AS Boolean NOT NULL, - bookmark INTEGER AS Boolean NOT NULL, - last_page_read INTEGER NOT NULL, - chapter_number REAL NOT NULL, - source_order INTEGER NOT NULL, - date_fetch INTEGER NOT NULL, - date_upload INTEGER NOT NULL, - last_modified_at INTEGER NOT NULL DEFAULT 0, - version INTEGER NOT NULL DEFAULT 0, - is_syncing INTEGER NOT NULL DEFAULT 0, - memo BLOB AS JsonObject NOT NULL DEFAULT '{}', - FOREIGN KEY(manga_id) REFERENCES manga (id) - ON DELETE CASCADE -); - -CREATE INDEX chapters_manga_id_index ON chapters(manga_id); -CREATE INDEX chapters_unread_by_manga_index ON chapters(manga_id, read) WHERE read = 0; -CREATE INDEX idx_chapters_url ON chapters(url); - -CREATE TRIGGER update_last_modified_at_chapters -AFTER UPDATE ON chapters -FOR EACH ROW -BEGIN - UPDATE chapters - SET last_modified_at = strftime('%s', 'now') - WHERE _id = new._id; -END; - -getChapterById: -SELECT * -FROM chapters -WHERE _id = :id; - -getChaptersByMangaId: -SELECT C.* -FROM chapters C -LEFT JOIN excluded_scanlators ES -ON C.manga_id = ES.manga_id -AND C.scanlator = ES.scanlator -WHERE C.manga_id = :mangaId -AND ( - :applyScanlatorFilter = 0 - OR ES.scanlator IS NULL -); - -getScanlatorsByMangaId: -SELECT scanlator -FROM chapters -WHERE manga_id = :mangaId; - -getBookmarkedChaptersByMangaId: -SELECT * -FROM chapters -WHERE bookmark -AND manga_id = :mangaId; - -getChapterByUrl: -SELECT * -FROM chapters -WHERE url = :chapterUrl; - -getChapterByUrlAndMangaId: -SELECT * -FROM chapters -WHERE url = :chapterUrl -AND manga_id = :mangaId -LIMIT 1; - -removeChaptersWithIds: -DELETE FROM chapters -WHERE _id IN :chapterIds; - -resetIsSyncing: -UPDATE chapters -SET is_syncing = 0 -WHERE is_syncing = 1; - -insert: -INSERT INTO chapters(manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, last_modified_at, version, is_syncing, memo) -VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, 0, :version, 0, :memo); - -insertReturningId: -INSERT INTO chapters(manga_id, url, name, scanlator, read, bookmark, last_page_read, chapter_number, source_order, date_fetch, date_upload, last_modified_at, version, is_syncing, memo) -VALUES (:mangaId, :url, :name, :scanlator, :read, :bookmark, :lastPageRead, :chapterNumber, :sourceOrder, :dateFetch, :dateUpload, 0, :version, 0, :memo) -RETURNING _id; - -update: -UPDATE chapters -SET manga_id = coalesce(:mangaId, manga_id), - url = coalesce(:url, url), - name = coalesce(:name, name), - scanlator = coalesce(:scanlator, scanlator), - read = coalesce(:read, read), - bookmark = coalesce(:bookmark, bookmark), - last_page_read = coalesce(:lastPageRead, last_page_read), - chapter_number = coalesce(:chapterNumber, chapter_number), - source_order = coalesce(:sourceOrder, source_order), - date_fetch = coalesce(:dateFetch, date_fetch), - date_upload = coalesce(:dateUpload, date_upload), - version = coalesce(:version, version), - is_syncing = coalesce(:isSyncing, is_syncing), - memo = coalesce(:memo, memo) -WHERE _id = :chapterId; diff --git a/data/src/main/sqldelight/tachiyomi/data/history.sq b/data/src/main/sqldelight/tachiyomi/data/history.sq index 4e459db004b..d22d58a57a5 100644 --- a/data/src/main/sqldelight/tachiyomi/data/history.sq +++ b/data/src/main/sqldelight/tachiyomi/data/history.sq @@ -5,7 +5,7 @@ CREATE TABLE history( chapter_id INTEGER NOT NULL UNIQUE, last_read INTEGER AS Date, time_read INTEGER NOT NULL, - FOREIGN KEY(chapter_id) REFERENCES chapters (_id) + FOREIGN KEY(chapter_id) REFERENCES chapter (id) ON DELETE CASCADE ); @@ -19,9 +19,9 @@ H.chapter_id, H.last_read, H.time_read FROM history H -JOIN chapters C -ON H.chapter_id = C._id -WHERE C.manga_id = :mangaId AND C._id = H.chapter_id; +JOIN chapter C +ON H.chapter_id = C.id +WHERE C.manga_id = :mangaId AND C.id = H.chapter_id; getHistoryByChapterUrlAndMangaId: SELECT @@ -30,9 +30,9 @@ H.chapter_id, H.last_read, H.time_read FROM history H -JOIN chapters C -ON H.chapter_id = C._id -WHERE C.url = :chapterUrl AND C.manga_id = :mangaId AND C._id = H.chapter_id +JOIN chapter C +ON H.chapter_id = C.id +WHERE C.remote_url = :chapterUrl AND C.manga_id = :mangaId AND C.id = H.chapter_id LIMIT 1; resetHistoryById: @@ -46,10 +46,10 @@ SET last_read = 0 WHERE _id IN ( SELECT H._id FROM manga M - INNER JOIN chapters C + INNER JOIN chapter C ON M.id = C.manga_id INNER JOIN history H - ON C._id = H.chapter_id + ON C.id = H.chapter_id WHERE M.id = :mangaId ); diff --git a/data/src/main/sqldelight/tachiyomi/data/manga.sq b/data/src/main/sqldelight/tachiyomi/data/manga.sq index 1a08b10c986..0e1a4b8f3b3 100644 --- a/data/src/main/sqldelight/tachiyomi/data/manga.sq +++ b/data/src/main/sqldelight/tachiyomi/data/manga.sq @@ -63,9 +63,9 @@ getReadMangaNotInLibrary: SELECT * FROM manga WHERE user_favorite_at IS NULL AND id IN ( - SELECT DISTINCT chapters.manga_id - FROM chapters - WHERE read = 1 OR last_page_read != 0 + SELECT DISTINCT chapter.manga_id + FROM chapter + WHERE user_read = 1 OR user_last_page_read != 0 ); getAllManga: @@ -120,11 +120,11 @@ chapter_counts AS ( M.id AS manga_id, count(*) AS chapter_count FROM duplicates M - JOIN chapters C + JOIN chapter C ON M.id = C.manga_id LEFT JOIN excluded_scanlators ES ON C.manga_id = ES.manga_id - AND C.scanlator = ES.scanlator + AND C.remote_scanlator = ES.scanlator WHERE ES.scanlator IS NULL GROUP BY M.id ) @@ -188,9 +188,9 @@ AND ( :keepReadManga = 0 OR id NOT IN ( SELECT DISTINCT manga_id - FROM chapters - WHERE read = 1 - OR last_page_read != 0 + FROM chapter + WHERE user_read = 1 + OR user_last_page_read != 0 ) ); diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index 16f58ddbfaf..b8554eee706 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -139,34 +139,156 @@ BEGIN SET state_modified_at = strftime('%s', 'now') WHERE id = new.id; END; + +-- --------------------------------------------------------------------------------------------------------- +-- table - chapter +-- --------------------------------------------------------------------------------------------------------- + +-- The entries that merged in the previous group brought their chapters with them +UPDATE chapters +SET manga_id = (SELECT keep_id FROM duplicate_mangas WHERE _id = chapters.manga_id) +WHERE manga_id IN (SELECT _id FROM duplicate_mangas WHERE _id != keep_id); + +-- Moving the chapters over brings rows for the same chapter together, and duplicates like those are +-- what issue #647 is about in the first place. The one with reading history is kept, the oldest +-- otherwise, and the read state and history of the others are merged into it. +CREATE TEMP TABLE duplicate_chapters AS +SELECT + _id, + first_value(_id) OVER (PARTITION BY manga_id, url ORDER BY has_history DESC, _id ASC) AS keep_id +FROM ( + SELECT + _id, + manga_id, + url, + EXISTS(SELECT 1 FROM history WHERE history.chapter_id = chapters._id) AS has_history + FROM chapters + WHERE (manga_id, url) IN ( + SELECT manga_id, url + FROM chapters + GROUP BY manga_id, url + HAVING count(*) > 1 + ) +); + +UPDATE chapters +SET read = merged.read, + bookmark = merged.bookmark, + last_page_read = merged.last_page_read, + date_fetch = merged.date_fetch, + date_upload = merged.date_upload +FROM ( + SELECT + D.keep_id, + max(C.read) AS read, + max(C.bookmark) AS bookmark, + max(C.last_page_read) AS last_page_read, + -- 0 means the fetch date was never recorded rather than "the beginning of time", so it only + -- wins when no copy recorded one. Updates only lists chapters fetched after the entry joined + -- the library, so letting a 0 through would hide the merged chapter from it. + coalesce(min(nullif(C.date_fetch, 0)), 0) AS date_fetch, + max(C.date_upload) AS date_upload + FROM duplicate_chapters D + JOIN chapters C + ON C._id = D._id + GROUP BY D.keep_id +) AS merged +WHERE chapters._id = merged.keep_id; + +-- The kept chapter has a history entry whenever any of its duplicates has one +UPDATE history +SET last_read = merged.last_read, + time_read = merged.time_read +FROM ( + SELECT + D.keep_id, + max(H.last_read) AS last_read, + -- Each duplicate accumulated its own reading time, so the durations add up; only the + -- last-read timestamp is a point in time and takes the latest. + sum(H.time_read) AS time_read + FROM duplicate_chapters D + JOIN history H + ON H.chapter_id = D._id + GROUP BY D.keep_id +) AS merged +WHERE history.chapter_id = merged.keep_id; + +-- Foreign keys are off during migrations, so the history of a discarded chapter goes explicitly +DELETE FROM history +WHERE chapter_id IN (SELECT _id FROM duplicate_chapters WHERE _id != keep_id); + +DELETE FROM chapters +WHERE _id IN (SELECT _id FROM duplicate_chapters WHERE _id != keep_id); + +DROP TABLE duplicate_chapters; + +CREATE TABLE chapter( + id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER NOT NULL, + remote_url TEXT NOT NULL, + remote_name TEXT NOT NULL, + remote_scanlator TEXT, + remote_chapter_number REAL NOT NULL, + remote_date_upload INTEGER NOT NULL, + remote_order INTEGER NOT NULL, + remote_memo BLOB NOT NULL, + user_read INTEGER NOT NULL, + user_bookmark INTEGER NOT NULL, + user_last_page_read INTEGER NOT NULL, + state_date_fetch INTEGER NOT NULL, + state_modified_at INTEGER NOT NULL, + UNIQUE(manga_id, remote_url), + FOREIGN KEY(manga_id) REFERENCES manga (id) + ON DELETE CASCADE +); + +INSERT INTO chapter( + id, manga_id, remote_url, remote_name, remote_scanlator, remote_chapter_number, remote_date_upload, + remote_order, remote_memo, user_read, user_bookmark, user_last_page_read, state_date_fetch, state_modified_at +) +SELECT + _id, manga_id, url, name, scanlator, chapter_number, date_upload, + source_order, memo, read, bookmark, last_page_read, date_fetch, last_modified_at +FROM chapters; + +DROP TABLE chapters; + +CREATE TRIGGER update_modified_at_chapter +AFTER UPDATE ON chapter +FOR EACH ROW +BEGIN + UPDATE chapter + SET state_modified_at = strftime('%s', 'now') + WHERE id = new.id; +END; -- The views read the columns that just changed, so they come back in their new shape CREATE VIEW historyView AS SELECT history._id AS id, manga.id AS mangaId, - chapters._id AS chapterId, + chapter.id AS chapterId, manga.remote_title AS title, manga.remote_cover AS thumbnailUrl, manga.source_id AS source, manga.user_favorite_at AS favoriteAt, manga.state_cover_last_modified, - chapters.chapter_number AS chapterNumber, + chapter.remote_chapter_number AS chapterNumber, history.last_read AS readAt, history.time_read AS readDuration, max_last_read.last_read AS maxReadAt, max_last_read.chapter_id AS maxReadAtChapterId FROM manga -JOIN chapters -ON manga.id = chapters.manga_id +JOIN chapter +ON manga.id = chapter.manga_id JOIN history -ON chapters._id = history.chapter_id +ON chapter.id = history.chapter_id JOIN ( - SELECT chapters.manga_id,chapters._id AS chapter_id, MAX(history.last_read) AS last_read - FROM chapters JOIN history - ON chapters._id = history.chapter_id - GROUP BY chapters.manga_id + SELECT chapter.manga_id,chapter.id AS chapter_id, MAX(history.last_read) AS last_read + FROM chapter JOIN history + ON chapter.id = history.chapter_id + GROUP BY chapter.manga_id ) AS max_last_read -ON chapters.manga_id = max_last_read.manga_id; +ON chapter.manga_id = max_last_read.manga_id; CREATE VIEW libraryView AS SELECT @@ -181,21 +303,21 @@ SELECT FROM manga M LEFT JOIN ( SELECT - chapters.manga_id, + chapter.manga_id, count(*) AS total, - sum(read) AS readCount, - coalesce(max(chapters.date_upload), 0) AS latestUpload, + sum(user_read) AS readCount, + coalesce(max(chapter.remote_date_upload), 0) AS latestUpload, coalesce(max(history.last_read), 0) AS lastRead, - coalesce(max(chapters.date_fetch), 0) AS fetchedAt, - sum(chapters.bookmark) AS bookmarkCount - FROM chapters + coalesce(max(chapter.state_date_fetch), 0) AS fetchedAt, + sum(chapter.user_bookmark) AS bookmarkCount + FROM chapter LEFT JOIN excluded_scanlators - ON chapters.manga_id = excluded_scanlators.manga_id - AND chapters.scanlator = excluded_scanlators.scanlator + ON chapter.manga_id = excluded_scanlators.manga_id + AND chapter.remote_scanlator = excluded_scanlators.scanlator LEFT JOIN history - ON chapters._id = history.chapter_id + ON chapter.id = history.chapter_id WHERE excluded_scanlators.scanlator IS NULL - GROUP BY chapters.manga_id + GROUP BY chapter.manga_id ) AS C ON M.id = C.manga_id LEFT JOIN ( @@ -210,25 +332,25 @@ CREATE VIEW updatesView AS SELECT manga.id AS mangaId, manga.remote_title AS mangaTitle, - chapters._id AS chapterId, - chapters.name AS chapterName, - chapters.scanlator, - chapters.url AS chapterUrl, - chapters.read, - chapters.bookmark, - chapters.last_page_read, + chapter.id AS chapterId, + chapter.remote_name AS chapterName, + chapter.remote_scanlator AS scanlator, + chapter.remote_url AS chapterUrl, + chapter.user_read AS read, + chapter.user_bookmark AS bookmark, + chapter.user_last_page_read AS last_page_read, manga.source_id AS source, manga.user_favorite_at AS favoriteAt, manga.remote_cover AS thumbnailUrl, manga.state_cover_last_modified AS coverLastModified, - chapters.date_upload AS dateUpload, - chapters.date_fetch AS datefetch, + chapter.remote_date_upload AS dateUpload, + chapter.state_date_fetch AS datefetch, excluded_scanlators.scanlator AS excludedScanlator -FROM manga JOIN chapters -ON manga.id = chapters.manga_id +FROM manga JOIN chapter +ON manga.id = chapter.manga_id LEFT JOIN excluded_scanlators ON manga.id = excluded_scanlators.manga_id -AND chapters.scanlator = excluded_scanlators.scanlator +AND chapter.remote_scanlator = excluded_scanlators.scanlator WHERE user_favorite_at IS NOT NULL -AND date_fetch > user_favorite_at -ORDER BY date_fetch DESC; +AND state_date_fetch > user_favorite_at +ORDER BY state_date_fetch DESC; diff --git a/data/src/main/sqldelight/tachiyomi/view/historyView.sq b/data/src/main/sqldelight/tachiyomi/view/historyView.sq index 632e74f5b08..ed89e334ee4 100644 --- a/data/src/main/sqldelight/tachiyomi/view/historyView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/historyView.sq @@ -2,29 +2,29 @@ CREATE VIEW historyView AS SELECT history._id AS id, manga.id AS mangaId, - chapters._id AS chapterId, + chapter.id AS chapterId, manga.remote_title AS title, manga.remote_cover AS thumbnailUrl, manga.source_id AS source, manga.user_favorite_at AS favoriteAt, manga.state_cover_last_modified, - chapters.chapter_number AS chapterNumber, + chapter.remote_chapter_number AS chapterNumber, history.last_read AS readAt, history.time_read AS readDuration, max_last_read.last_read AS maxReadAt, max_last_read.chapter_id AS maxReadAtChapterId FROM manga -JOIN chapters -ON manga.id = chapters.manga_id +JOIN chapter +ON manga.id = chapter.manga_id JOIN history -ON chapters._id = history.chapter_id +ON chapter.id = history.chapter_id JOIN ( - SELECT chapters.manga_id,chapters._id AS chapter_id, MAX(history.last_read) AS last_read - FROM chapters JOIN history - ON chapters._id = history.chapter_id - GROUP BY chapters.manga_id + SELECT chapter.manga_id,chapter.id AS chapter_id, MAX(history.last_read) AS last_read + FROM chapter JOIN history + ON chapter.id = history.chapter_id + GROUP BY chapter.manga_id ) AS max_last_read -ON chapters.manga_id = max_last_read.manga_id; +ON chapter.manga_id = max_last_read.manga_id; history: SELECT diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index 3d4a2c07b99..d04ab069ffd 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -11,21 +11,21 @@ SELECT FROM manga M LEFT JOIN ( SELECT - chapters.manga_id, + chapter.manga_id, count(*) AS total, - sum(read) AS readCount, - coalesce(max(chapters.date_upload), 0) AS latestUpload, + sum(user_read) AS readCount, + coalesce(max(chapter.remote_date_upload), 0) AS latestUpload, coalesce(max(history.last_read), 0) AS lastRead, - coalesce(max(chapters.date_fetch), 0) AS fetchedAt, - sum(chapters.bookmark) AS bookmarkCount - FROM chapters + coalesce(max(chapter.state_date_fetch), 0) AS fetchedAt, + sum(chapter.user_bookmark) AS bookmarkCount + FROM chapter LEFT JOIN excluded_scanlators - ON chapters.manga_id = excluded_scanlators.manga_id - AND chapters.scanlator = excluded_scanlators.scanlator + ON chapter.manga_id = excluded_scanlators.manga_id + AND chapter.remote_scanlator = excluded_scanlators.scanlator LEFT JOIN history - ON chapters._id = history.chapter_id + ON chapter.id = history.chapter_id WHERE excluded_scanlators.scanlator IS NULL - GROUP BY chapters.manga_id + GROUP BY chapter.manga_id ) AS C ON M.id = C.manga_id LEFT JOIN ( diff --git a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq index c95a4867334..76045ad71f4 100644 --- a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq @@ -2,28 +2,28 @@ CREATE VIEW updatesView AS SELECT manga.id AS mangaId, manga.remote_title AS mangaTitle, - chapters._id AS chapterId, - chapters.name AS chapterName, - chapters.scanlator, - chapters.url AS chapterUrl, - chapters.read, - chapters.bookmark, - chapters.last_page_read, + chapter.id AS chapterId, + chapter.remote_name AS chapterName, + chapter.remote_scanlator AS scanlator, + chapter.remote_url AS chapterUrl, + chapter.user_read AS read, + chapter.user_bookmark AS bookmark, + chapter.user_last_page_read AS last_page_read, manga.source_id AS source, manga.user_favorite_at AS favoriteAt, manga.remote_cover AS thumbnailUrl, manga.state_cover_last_modified AS coverLastModified, - chapters.date_upload AS dateUpload, - chapters.date_fetch AS datefetch, + chapter.remote_date_upload AS dateUpload, + chapter.state_date_fetch AS datefetch, excluded_scanlators.scanlator AS excludedScanlator -FROM manga JOIN chapters -ON manga.id = chapters.manga_id +FROM manga JOIN chapter +ON manga.id = chapter.manga_id LEFT JOIN excluded_scanlators ON manga.id = excluded_scanlators.manga_id -AND chapters.scanlator = excluded_scanlators.scanlator +AND chapter.remote_scanlator = excluded_scanlators.scanlator WHERE user_favorite_at IS NOT NULL -AND date_fetch > user_favorite_at -ORDER BY date_fetch DESC; +AND state_date_fetch > user_favorite_at +ORDER BY state_date_fetch DESC; getRecentUpdates: SELECT * From 99d8220261e5ad66226660a5b87434046783faa9 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:36:17 +0600 Subject: [PATCH 03/11] table - category Rebuild `categories` as `category` with `_id` -> `id` and `sort` -> `order`, the name it already had everywhere above the database. `order` gains a UNIQUE constraint, since two categories sharing a position was never meaningful. `mangas_categories` is rebuilt in the migration only to point its foreign key at the new table; it gets its real rebuild in its own step. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../restore/restorers/CategoriesRestorer.kt | 2 +- .../data/category/CategoryRepositoryImpl.kt | 22 ++++---- .../data/{categories.sq => category.sq} | 54 +++++++++---------- .../tachiyomi/data/mangas_categories.sq | 2 +- .../sqldelight/tachiyomi/migrations/14.sqm | 39 +++++++++++++- 5 files changed, 78 insertions(+), 41 deletions(-) rename data/src/main/sqldelight/tachiyomi/data/{categories.sq => category.sq} (51%) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt index 16d747e8b9b..2453ad886ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/CategoriesRestorer.kt @@ -26,7 +26,7 @@ class CategoriesRestorer( val dbCategory = dbCategoriesByName[it.name] if (dbCategory != null) return@map dbCategory val order = nextOrder++ - database.categoriesQueries + database.categoryQueries .insert(it.name, order, it.flags) .let { id -> it.toCategory(id).copy(order = order) } } diff --git a/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt index 90f2f75f043..94196d78035 100644 --- a/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/category/CategoryRepositoryImpl.kt @@ -20,37 +20,37 @@ class CategoryRepositoryImpl( ) : CategoryRepository { override suspend fun get(id: Long): Category? { - return database.categoriesQueries + return database.categoryQueries .getCategory(id, ::mapCategory) .awaitAsOneOrNull() } override suspend fun getAll(): List { - return database.categoriesQueries + return database.categoryQueries .getCategories(::mapCategory) .awaitAsList() } override fun getAllAsFlow(): Flow> { - return database.categoriesQueries + return database.categoryQueries .getCategories(::mapCategory) .subscribeToList() } override suspend fun getCategoriesByMangaId(mangaId: Long): List { - return database.categoriesQueries + return database.categoryQueries .getCategoriesByMangaId(mangaId, ::mapCategory) .awaitAsList() } override fun getCategoriesByMangaIdAsFlow(mangaId: Long): Flow> { - return database.categoriesQueries + return database.categoryQueries .getCategoriesByMangaId(mangaId, ::mapCategory) .subscribeToList() } override suspend fun insert(category: Category) { - database.categoriesQueries.insert( + database.categoryQueries.insert( name = category.name, order = category.order, flags = category.flags, @@ -58,27 +58,27 @@ class CategoryRepositoryImpl( } override suspend fun updateName(categoryId: Long, name: String) { - database.categoriesQueries.updateName(name = name, categoryId = categoryId) + database.categoryQueries.updateName(name = name, categoryId = categoryId) } override suspend fun updateFlags(categoryId: Long, flags: Long) { - database.categoriesQueries.updateFlags(flags = flags, categoryId = categoryId) + database.categoryQueries.updateFlags(flags = flags, categoryId = categoryId) } override suspend fun updateAllFlags(flags: Long?) { - database.categoriesQueries.updateAllFlags(flags = flags) + database.categoryQueries.updateAllFlags(flags = flags) } override suspend fun updateAllOrders(orderedIds: List) { database.transaction { orderedIds.forEachIndexed { index, categoryId -> - database.categoriesQueries.updateOrder(order = index.toLong(), categoryId = categoryId) + database.categoryQueries.updateOrder(order = index.toLong(), categoryId = categoryId) } } } override suspend fun delete(categoryId: Long) { - database.categoriesQueries.delete(categoryId = categoryId) + database.categoryQueries.delete(categoryId = categoryId) } private fun mapCategory( diff --git a/data/src/main/sqldelight/tachiyomi/data/categories.sq b/data/src/main/sqldelight/tachiyomi/data/category.sq similarity index 51% rename from data/src/main/sqldelight/tachiyomi/data/categories.sq rename to data/src/main/sqldelight/tachiyomi/data/category.sq index a94f38f934f..73a1bd74e25 100644 --- a/data/src/main/sqldelight/tachiyomi/data/categories.sq +++ b/data/src/main/sqldelight/tachiyomi/data/category.sq @@ -1,70 +1,70 @@ -CREATE TABLE categories( - _id INTEGER NOT NULL PRIMARY KEY, +CREATE TABLE category( + id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL, - sort INTEGER NOT NULL, + `order` INTEGER NOT NULL UNIQUE, flags INTEGER NOT NULL ); -- Insert system category -INSERT OR IGNORE INTO categories(_id, name, sort, flags) VALUES (0, "", -1, 0); +INSERT OR IGNORE INTO category(id, name, `order`, flags) VALUES (0, "", -1, 0); -- Disallow deletion of default category CREATE TRIGGER IF NOT EXISTS system_category_delete_trigger BEFORE DELETE -ON categories +ON category BEGIN SELECT CASE - WHEN old._id <= 0 THEN + WHEN old.id <= 0 THEN RAISE(ABORT, "System category can't be deleted") END; END; getCategory: SELECT * -FROM categories -WHERE _id = :id +FROM category +WHERE id = :id LIMIT 1; getCategories: SELECT -_id AS id, +id, name, -sort AS `order`, +`order`, flags -FROM categories -ORDER BY sort; +FROM category +ORDER BY `order`; getCategoriesByMangaId: SELECT -C._id AS id, +C.id, C.name, -C.sort AS `order`, +C.`order`, C.flags -FROM categories C +FROM category C JOIN mangas_categories MC -ON C._id = MC.category_id +ON C.id = MC.category_id WHERE MC.manga_id = :mangaId; insert: -INSERT INTO categories(name, sort, flags) +INSERT INTO category(name, `order`, flags) VALUES (:name, :order, :flags); delete: -DELETE FROM categories -WHERE _id = :categoryId; +DELETE FROM category +WHERE id = :categoryId; updateName: -UPDATE categories +UPDATE category SET name = :name -WHERE _id = :categoryId; +WHERE id = :categoryId; updateOrder: -UPDATE categories -SET sort = :order -WHERE _id = :categoryId; +UPDATE category +SET `order` = :order +WHERE id = :categoryId; updateFlags: -UPDATE categories +UPDATE category SET flags = :flags -WHERE _id = :categoryId; +WHERE id = :categoryId; updateAllFlags: -UPDATE categories SET +UPDATE category SET flags = coalesce(:flags, flags); diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq b/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq index bf386915912..fdb7c3a7177 100644 --- a/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq +++ b/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq @@ -2,7 +2,7 @@ CREATE TABLE mangas_categories( _id INTEGER NOT NULL PRIMARY KEY, manga_id INTEGER NOT NULL, category_id INTEGER NOT NULL, - FOREIGN KEY(category_id) REFERENCES categories (_id) + FOREIGN KEY(category_id) REFERENCES category (id) ON DELETE CASCADE, FOREIGN KEY(manga_id) REFERENCES manga (id) ON DELETE CASCADE diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index b8554eee706..cf4699b1f37 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -261,7 +261,44 @@ BEGIN SET state_modified_at = strftime('%s', 'now') WHERE id = new.id; END; --- The views read the columns that just changed, so they come back in their new shape + +-- --------------------------------------------------------------------------------------------------------- +-- table - category +-- --------------------------------------------------------------------------------------------------------- + +CREATE TABLE category( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + `order` INTEGER NOT NULL UNIQUE, + flags INTEGER NOT NULL +); + +-- `sort` was never unique, and reordering wrote colliding values often enough that it cannot simply be +-- copied under the new constraint. The rows keep the order they were displayed in and are renumbered from +-- 0, with ties broken by id. The system category is pinned to -1 rather than renumbered, because it is +-- identified by id 0 everywhere else and sorts ahead of the categories the user made. +INSERT INTO category(id, name, `order`, flags) +SELECT + _id, + name, + CASE + WHEN _id <= 0 THEN -1 + ELSE row_number() OVER (PARTITION BY (_id <= 0) ORDER BY sort, _id) - 1 + END, + flags +FROM categories; + +-- Dropping the table takes `system_category_delete_trigger` with it +DROP TABLE categories; + +CREATE TRIGGER IF NOT EXISTS system_category_delete_trigger BEFORE DELETE +ON category +BEGIN SELECT CASE + WHEN old.id <= 0 THEN + RAISE(ABORT, "System category can't be deleted") + END; +END; +-- The views come back unchanged; only the table under `mangas_categories` was replaced CREATE VIEW historyView AS SELECT history._id AS id, From 6916a241eb2c8879b2b26220b6cdf5b578971fd5 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:39:57 +0600 Subject: [PATCH 04/11] table - manga_category Rebuild `mangas_categories` as `manga_category`. The surrogate `_id` went unused, so the pair becomes the identity with a UNIQUE(manga_id, category_id) constraint; duplicate memberships were never meaningful. `idx_mangas_categories_manga_id` goes away because the unique constraint already indexes `manga_id` as its leading column. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../backup/restore/restorers/MangaRestorer.kt | 4 +-- .../data/manga/MangaRepositoryImpl.kt | 4 +-- .../sqldelight/tachiyomi/data/category.sq | 2 +- .../main/sqldelight/tachiyomi/data/manga.sq | 16 +++++----- .../tachiyomi/data/manga_category.sq | 19 +++++++++++ .../tachiyomi/data/mangas_categories.sq | 20 ------------ .../sqldelight/tachiyomi/migrations/14.sqm | 32 +++++++++++++++++-- .../sqldelight/tachiyomi/view/libraryView.sq | 2 +- .../sqldelight/tachiyomi/view/updatesView.sq | 16 +++++----- 9 files changed, 71 insertions(+), 44 deletions(-) create mode 100644 data/src/main/sqldelight/tachiyomi/data/manga_category.sq delete mode 100644 data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index a9c801dbfe9..0a3ef926cac 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -306,9 +306,9 @@ class MangaRestorer( if (mangaCategoriesToUpdate.isNotEmpty()) { database.transaction { - database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(manga.id) + database.manga_categoryQueries.deleteMangaCategoryByMangaId(manga.id) mangaCategoriesToUpdate.forEach { (mangaId, categoryId) -> - database.mangas_categoriesQueries.insert(mangaId, categoryId) + database.manga_categoryQueries.insert(mangaId, categoryId) } } } diff --git a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt index 1348ecf9763..4455dd76596 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaRepositoryImpl.kt @@ -127,9 +127,9 @@ class MangaRepositoryImpl( override suspend fun setMangaCategories(mangaId: Long, categoryIds: List) { database.transaction { - database.mangas_categoriesQueries.deleteMangaCategoryByMangaId(mangaId) + database.manga_categoryQueries.deleteMangaCategoryByMangaId(mangaId) categoryIds.forEach { categoryId -> - database.mangas_categoriesQueries.insert(mangaId, categoryId) + database.manga_categoryQueries.insert(mangaId, categoryId) } } } diff --git a/data/src/main/sqldelight/tachiyomi/data/category.sq b/data/src/main/sqldelight/tachiyomi/data/category.sq index 73a1bd74e25..e0926709ac9 100644 --- a/data/src/main/sqldelight/tachiyomi/data/category.sq +++ b/data/src/main/sqldelight/tachiyomi/data/category.sq @@ -38,7 +38,7 @@ C.name, C.`order`, C.flags FROM category C -JOIN mangas_categories MC +JOIN manga_category MC ON C.id = MC.category_id WHERE MC.manga_id = :mangaId; diff --git a/data/src/main/sqldelight/tachiyomi/data/manga.sq b/data/src/main/sqldelight/tachiyomi/data/manga.sq index 0e1a4b8f3b3..4e4ab063aec 100644 --- a/data/src/main/sqldelight/tachiyomi/data/manga.sq +++ b/data/src/main/sqldelight/tachiyomi/data/manga.sq @@ -145,12 +145,12 @@ AND ( -- includedEmpty being true expresses "don't care" state and bypasses the membership filter :includedEmpty OR EXISTS ( - SELECT 1 FROM mangas_categories - WHERE mangas_categories.manga_id = manga.id - AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories + SELECT 1 FROM manga_category + WHERE manga_category.manga_id = manga.id + AND COALESCE(manga_category.category_id, 0) IN :includedCategories ) OR (0 IN :includedCategories AND NOT EXISTS ( - SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = manga.id) + SELECT 1 FROM manga_category WHERE manga_category.manga_id = manga.id) ) ) AND ( @@ -158,13 +158,13 @@ AND ( :excludedEmpty OR ( NOT EXISTS ( - SELECT 1 FROM mangas_categories - WHERE mangas_categories.manga_id = manga.id - AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories + SELECT 1 FROM manga_category + WHERE manga_category.manga_id = manga.id + AND COALESCE(manga_category.category_id, 0) IN :excludedCategories ) AND ( 0 NOT IN :excludedCategories - OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = manga.id) + OR EXISTS (SELECT 1 FROM manga_category WHERE manga_category.manga_id = manga.id) ) ) ) diff --git a/data/src/main/sqldelight/tachiyomi/data/manga_category.sq b/data/src/main/sqldelight/tachiyomi/data/manga_category.sq new file mode 100644 index 00000000000..4974db32d38 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/manga_category.sq @@ -0,0 +1,19 @@ +CREATE TABLE manga_category( + manga_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + UNIQUE(manga_id, category_id), + FOREIGN KEY(category_id) REFERENCES category (id) + ON DELETE CASCADE, + FOREIGN KEY(manga_id) REFERENCES manga (id) + ON DELETE CASCADE +); + +CREATE INDEX manga_category_category_id_index ON manga_category(category_id); + +insert: +INSERT INTO manga_category(manga_id, category_id) +VALUES (:mangaId, :categoryId); + +deleteMangaCategoryByMangaId: +DELETE FROM manga_category +WHERE manga_id = :mangaId; diff --git a/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq b/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq deleted file mode 100644 index fdb7c3a7177..00000000000 --- a/data/src/main/sqldelight/tachiyomi/data/mangas_categories.sq +++ /dev/null @@ -1,20 +0,0 @@ -CREATE TABLE mangas_categories( - _id INTEGER NOT NULL PRIMARY KEY, - manga_id INTEGER NOT NULL, - category_id INTEGER NOT NULL, - FOREIGN KEY(category_id) REFERENCES category (id) - ON DELETE CASCADE, - FOREIGN KEY(manga_id) REFERENCES manga (id) - ON DELETE CASCADE -); - -CREATE INDEX idx_mangas_categories_manga_id ON mangas_categories(manga_id); -CREATE INDEX idx_mangas_categories_category_id ON mangas_categories(category_id); - -insert: -INSERT INTO mangas_categories(manga_id, category_id) -VALUES (:mangaId, :categoryId); - -deleteMangaCategoryByMangaId: -DELETE FROM mangas_categories -WHERE manga_id = :mangaId; \ No newline at end of file diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index cf4699b1f37..7d19eba4ffd 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -298,7 +298,35 @@ BEGIN SELECT CASE RAISE(ABORT, "System category can't be deleted") END; END; --- The views come back unchanged; only the table under `mangas_categories` was replaced + +-- --------------------------------------------------------------------------------------------------------- +-- table - manga_category +-- --------------------------------------------------------------------------------------------------------- + +-- The surrogate key is gone: a manga belongs to a category once, and that pair is the identity of the row +CREATE TABLE manga_category( + manga_id INTEGER NOT NULL, + category_id INTEGER NOT NULL, + UNIQUE(manga_id, category_id), + FOREIGN KEY(category_id) REFERENCES category (id) + ON DELETE CASCADE, + FOREIGN KEY(manga_id) REFERENCES manga (id) + ON DELETE CASCADE +); + +-- Entries merged in the manga group brought their category rows with them, so the same pair can appear +-- twice by now; membership carries nothing else, so the copies simply collapse. +INSERT INTO manga_category(manga_id, category_id) +SELECT DISTINCT coalesce(D.keep_id, MC.manga_id), MC.category_id +FROM mangas_categories MC +LEFT JOIN duplicate_mangas D +ON D._id = MC.manga_id; + +DROP TABLE mangas_categories; + +CREATE INDEX manga_category_category_id_index ON manga_category(category_id); + +-- The views come back reading the new table CREATE VIEW historyView AS SELECT history._id AS id, @@ -359,7 +387,7 @@ LEFT JOIN ( ON M.id = C.manga_id LEFT JOIN ( SELECT manga_id, group_concat(category_id) AS categories - FROM mangas_categories + FROM manga_category GROUP BY manga_id ) AS MC ON MC.manga_id = M.id diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index d04ab069ffd..aaa538653e2 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -30,7 +30,7 @@ LEFT JOIN ( ON M.id = C.manga_id LEFT JOIN ( SELECT manga_id, group_concat(category_id) AS categories - FROM mangas_categories + FROM manga_category GROUP BY manga_id ) AS MC ON MC.manga_id = M.id diff --git a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq index 76045ad71f4..24b4af37886 100644 --- a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq @@ -50,12 +50,12 @@ AND ( -- includedEmpty being true expresses "don't care" state and bypasses the membership filter :includedEmpty OR EXISTS ( - SELECT 1 FROM mangas_categories - WHERE mangas_categories.manga_id = mangaId - AND COALESCE(mangas_categories.category_id, 0) IN :includedCategories + SELECT 1 FROM manga_category + WHERE manga_category.manga_id = mangaId + AND COALESCE(manga_category.category_id, 0) IN :includedCategories ) OR (0 IN :includedCategories AND NOT EXISTS ( - SELECT 1 FROM mangas_categories mc WHERE mc.manga_id = mangaId) + SELECT 1 FROM manga_category mc WHERE mc.manga_id = mangaId) ) ) AND ( @@ -63,13 +63,13 @@ AND ( :excludedEmpty OR ( NOT EXISTS ( - SELECT 1 FROM mangas_categories - WHERE mangas_categories.manga_id = mangaId - AND COALESCE(mangas_categories.category_id, 0) IN :excludedCategories + SELECT 1 FROM manga_category + WHERE manga_category.manga_id = mangaId + AND COALESCE(manga_category.category_id, 0) IN :excludedCategories ) AND ( 0 NOT IN :excludedCategories - OR EXISTS (SELECT 1 FROM mangas_categories WHERE mangas_categories.manga_id = mangaId) + OR EXISTS (SELECT 1 FROM manga_category WHERE manga_category.manga_id = mangaId) ) ) ) From 13ddd7b50d80ee1f727f7c211762fd7e6544a188 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:24:55 +0600 Subject: [PATCH 05/11] table - history Rebuild `history` with `_id` -> `id`, `last_read` -> `read_at` and `time_read` -> `read_duration`; the old names read as booleans rather than the timestamp and duration they hold. `history_history_chapter_id_index` goes away because `chapter_id` is already UNIQUE and therefore indexed. A row also names the entry it belongs to now, not just the chapter. Reaching the entry meant joining through the chapter every time, and the entry is the thing history is read by. It is filled from the chapter it hangs off, and keyed to both: the entry first, then the chapter. A row whose chapter is already gone cannot name an entry and is dropped, which is what the foreign key would do to it once it is enforced again. The table is the only one here that keeps its name, so the original is renamed aside and the new one built under the real name. Building it beside the original and renaming it into place makes SQLite rewrite the stored definition, which would leave this table spelled differently from the same table on a fresh install. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../backup/restore/restorers/MangaRestorer.kt | 6 +-- app/src/main/java/mihon/app/di/AppBindings.kt | 2 +- .../main/sqldelight/tachiyomi/data/history.sq | 49 +++++++++-------- .../sqldelight/tachiyomi/migrations/14.sqm | 53 ++++++++++++++++--- .../sqldelight/tachiyomi/view/historyView.sq | 10 ++-- .../sqldelight/tachiyomi/view/libraryView.sq | 2 +- 6 files changed, 83 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 0a3ef926cac..20b2564dc7e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -336,12 +336,12 @@ class MangaRestorer( // Update history entry item.copy( - id = dbHistory._id, + id = dbHistory.id, chapterId = dbHistory.chapter_id, - readAt = max(item.readAt?.time ?: 0L, dbHistory.last_read?.time ?: 0L) + readAt = max(item.readAt?.time ?: 0L, dbHistory.read_at?.time ?: 0L) .takeIf { it > 0L } ?.let { Date(it) }, - readDuration = max(item.readDuration, dbHistory.time_read) - dbHistory.time_read, + readDuration = max(item.readDuration, dbHistory.read_duration) - dbHistory.read_duration, ) } diff --git a/app/src/main/java/mihon/app/di/AppBindings.kt b/app/src/main/java/mihon/app/di/AppBindings.kt index 0d065aec462..76cb51877fe 100644 --- a/app/src/main/java/mihon/app/di/AppBindings.kt +++ b/app/src/main/java/mihon/app/di/AppBindings.kt @@ -47,7 +47,7 @@ object AppBindings { return Database( driver = driver, historyAdapter = History.Adapter( - last_readAdapter = DateColumnAdapter, + read_atAdapter = DateColumnAdapter, ), mangaAdapter = Manga.Adapter( remote_genreAdapter = StringListColumnAdapter, diff --git a/data/src/main/sqldelight/tachiyomi/data/history.sq b/data/src/main/sqldelight/tachiyomi/data/history.sq index d22d58a57a5..8daac320ea2 100644 --- a/data/src/main/sqldelight/tachiyomi/data/history.sq +++ b/data/src/main/sqldelight/tachiyomi/data/history.sq @@ -1,23 +1,26 @@ import java.util.Date; CREATE TABLE history( - _id INTEGER NOT NULL PRIMARY KEY, + id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER NOT NULL, chapter_id INTEGER NOT NULL UNIQUE, - last_read INTEGER AS Date, - time_read INTEGER NOT NULL, + read_at INTEGER AS Date, + read_duration INTEGER NOT NULL, + FOREIGN KEY(manga_id) REFERENCES manga (id) + ON DELETE CASCADE, FOREIGN KEY(chapter_id) REFERENCES chapter (id) ON DELETE CASCADE ); -CREATE INDEX history_history_chapter_id_index ON history(chapter_id); -CREATE INDEX idx_history_last_read ON history(last_read); +CREATE INDEX history_manga_id_index ON history(manga_id); +CREATE INDEX history_read_at_index ON history(read_at); getHistoryByMangaId: SELECT -H._id, +H.id, H.chapter_id, -H.last_read, -H.time_read +H.read_at, +H.read_duration FROM history H JOIN chapter C ON H.chapter_id = C.id @@ -25,10 +28,10 @@ WHERE C.manga_id = :mangaId AND C.id = H.chapter_id; getHistoryByChapterUrlAndMangaId: SELECT -H._id, +H.id, H.chapter_id, -H.last_read, -H.time_read +H.read_at, +H.read_duration FROM history H JOIN chapter C ON H.chapter_id = C.id @@ -37,14 +40,14 @@ LIMIT 1; resetHistoryById: UPDATE history -SET last_read = 0 -WHERE _id = :historyId; +SET read_at = 0 +WHERE id = :historyId; resetHistoryByMangaId: UPDATE history -SET last_read = 0 -WHERE _id IN ( - SELECT H._id +SET read_at = 0 +WHERE id IN ( + SELECT H.id FROM manga M INNER JOIN chapter C ON M.id = C.manga_id @@ -58,18 +61,20 @@ DELETE FROM history; removeResettedHistory: DELETE FROM history -WHERE last_read = 0; +WHERE read_at = 0; upsert: -INSERT INTO history(chapter_id, last_read, time_read) -VALUES (:chapterId, :readAt, :time_read) +INSERT INTO history(manga_id, chapter_id, read_at, read_duration) +SELECT C.manga_id, :chapterId, :readAt, :time_read +FROM chapter C +WHERE C.id = :chapterId ON CONFLICT(chapter_id) DO UPDATE SET - last_read = :readAt, - time_read = time_read + :time_read + read_at = :readAt, + read_duration = read_duration + :time_read WHERE chapter_id = :chapterId; getReadDuration: -SELECT coalesce(sum(time_read), 0) +SELECT coalesce(sum(read_duration), 0) FROM history; diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index 7d19eba4ffd..0d5f3eaa83d 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -326,10 +326,49 @@ DROP TABLE mangas_categories; CREATE INDEX manga_category_category_id_index ON manga_category(category_id); --- The views come back reading the new table +-- --------------------------------------------------------------------------------------------------------- +-- table - history +-- --------------------------------------------------------------------------------------------------------- + +-- `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. +-- 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 +-- dropped at the top of this migration, so nothing refers to the name being moved. +ALTER TABLE history RENAME TO history_temp; + +CREATE TABLE history( + id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER NOT NULL, + chapter_id INTEGER NOT NULL UNIQUE, + read_at INTEGER, + read_duration INTEGER NOT NULL, + FOREIGN KEY(manga_id) REFERENCES manga (id) + ON DELETE CASCADE, + FOREIGN KEY(chapter_id) REFERENCES chapter (id) + ON DELETE CASCADE +); + +-- 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. +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 +FROM history_temp H +JOIN chapter C +ON C.id = H.chapter_id; + +DROP TABLE history_temp; + +CREATE INDEX history_manga_id_index ON history(manga_id); +CREATE INDEX history_read_at_index ON history(read_at); + +-- The views come back reading the renamed columns CREATE VIEW historyView AS SELECT - history._id AS id, + history.id AS id, manga.id AS mangaId, chapter.id AS chapterId, manga.remote_title AS title, @@ -338,9 +377,9 @@ SELECT manga.user_favorite_at AS favoriteAt, manga.state_cover_last_modified, chapter.remote_chapter_number AS chapterNumber, - history.last_read AS readAt, - history.time_read AS readDuration, - max_last_read.last_read AS maxReadAt, + history.read_at AS readAt, + history.read_duration AS readDuration, + max_last_read.read_at AS maxReadAt, max_last_read.chapter_id AS maxReadAtChapterId FROM manga JOIN chapter @@ -348,7 +387,7 @@ ON manga.id = chapter.manga_id JOIN history ON chapter.id = history.chapter_id JOIN ( - SELECT chapter.manga_id,chapter.id AS chapter_id, MAX(history.last_read) AS last_read + SELECT chapter.manga_id,chapter.id AS chapter_id, MAX(history.read_at) AS read_at FROM chapter JOIN history ON chapter.id = history.chapter_id GROUP BY chapter.manga_id @@ -372,7 +411,7 @@ LEFT JOIN ( count(*) AS total, sum(user_read) AS readCount, coalesce(max(chapter.remote_date_upload), 0) AS latestUpload, - coalesce(max(history.last_read), 0) AS lastRead, + coalesce(max(history.read_at), 0) AS lastRead, coalesce(max(chapter.state_date_fetch), 0) AS fetchedAt, sum(chapter.user_bookmark) AS bookmarkCount FROM chapter diff --git a/data/src/main/sqldelight/tachiyomi/view/historyView.sq b/data/src/main/sqldelight/tachiyomi/view/historyView.sq index ed89e334ee4..412f755a96c 100644 --- a/data/src/main/sqldelight/tachiyomi/view/historyView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/historyView.sq @@ -1,6 +1,6 @@ CREATE VIEW historyView AS SELECT - history._id AS id, + history.id AS id, manga.id AS mangaId, chapter.id AS chapterId, manga.remote_title AS title, @@ -9,9 +9,9 @@ SELECT manga.user_favorite_at AS favoriteAt, manga.state_cover_last_modified, chapter.remote_chapter_number AS chapterNumber, - history.last_read AS readAt, - history.time_read AS readDuration, - max_last_read.last_read AS maxReadAt, + history.read_at AS readAt, + history.read_duration AS readDuration, + max_last_read.read_at AS maxReadAt, max_last_read.chapter_id AS maxReadAtChapterId FROM manga JOIN chapter @@ -19,7 +19,7 @@ ON manga.id = chapter.manga_id JOIN history ON chapter.id = history.chapter_id JOIN ( - SELECT chapter.manga_id,chapter.id AS chapter_id, MAX(history.last_read) AS last_read + SELECT chapter.manga_id,chapter.id AS chapter_id, MAX(history.read_at) AS read_at FROM chapter JOIN history ON chapter.id = history.chapter_id GROUP BY chapter.manga_id diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index aaa538653e2..e65cffa17a9 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -15,7 +15,7 @@ LEFT JOIN ( count(*) AS total, sum(user_read) AS readCount, coalesce(max(chapter.remote_date_upload), 0) AS latestUpload, - coalesce(max(history.last_read), 0) AS lastRead, + coalesce(max(history.read_at), 0) AS lastRead, coalesce(max(chapter.state_date_fetch), 0) AS fetchedAt, sum(chapter.user_bookmark) AS bookmarkCount FROM chapter From 3c6a139400a95a42deb6a10380ce91f779332950 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:42:40 +0600 Subject: [PATCH 06/11] table - excluded_scanlator Rebuild `excluded_scanlators` as `excluded_scanlator`, singular like the other tables. The pair becomes the identity with a UNIQUE(manga_id, scanlator) constraint; excluding the same scanlator twice for one entry was never meaningful. `excluded_scanlators_manga_id_index` goes away because the unique constraint already indexes `manga_id` as its leading column. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../manga/interactor/GetExcludedScanlators.kt | 4 +- .../manga/interactor/SetExcludedScanlators.kt | 6 +-- .../create/creators/MangaBackupCreator.kt | 2 +- .../backup/restore/restorers/MangaRestorer.kt | 4 +- .../main/sqldelight/tachiyomi/data/chapter.sq | 2 +- ...ed_scanlators.sq => excluded_scanlator.sq} | 11 +++-- .../main/sqldelight/tachiyomi/data/manga.sq | 2 +- .../sqldelight/tachiyomi/migrations/14.sqm | 41 +++++++++++++++---- .../sqldelight/tachiyomi/view/libraryView.sq | 8 ++-- .../sqldelight/tachiyomi/view/updatesView.sq | 8 ++-- 10 files changed, 55 insertions(+), 33 deletions(-) rename data/src/main/sqldelight/tachiyomi/data/{excluded_scanlators.sq => excluded_scanlator.sq} (50%) diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt index f6dd5c758fa..f5d110cfc11 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/GetExcludedScanlators.kt @@ -13,14 +13,14 @@ class GetExcludedScanlators( ) { suspend fun await(mangaId: Long): Set { - return database.excluded_scanlatorsQueries + return database.excluded_scanlatorQueries .getExcludedScanlatorsByMangaId(mangaId) .awaitAsList() .toSet() } fun subscribe(mangaId: Long): Flow> { - return database.excluded_scanlatorsQueries + return database.excluded_scanlatorQueries .getExcludedScanlatorsByMangaId(mangaId) .subscribeToList() .map { it.toSet() } diff --git a/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt b/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt index 37b2778f278..e7c5d8ce346 100644 --- a/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt +++ b/app/src/main/java/eu/kanade/domain/manga/interactor/SetExcludedScanlators.kt @@ -11,16 +11,16 @@ class SetExcludedScanlators( suspend fun await(mangaId: Long, excludedScanlators: Set) { database.transaction { - val currentExcluded = database.excluded_scanlatorsQueries + val currentExcluded = database.excluded_scanlatorQueries .getExcludedScanlatorsByMangaId(mangaId) .awaitAsList() .toSet() val toAdd = excludedScanlators.minus(currentExcluded) for (scanlator in toAdd) { - database.excluded_scanlatorsQueries.insert(mangaId, scanlator) + database.excluded_scanlatorQueries.insert(mangaId, scanlator) } val toRemove = currentExcluded.minus(excludedScanlators) - database.excluded_scanlatorsQueries.remove(mangaId, toRemove) + database.excluded_scanlatorQueries.remove(mangaId, toRemove) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index cf25d19f84c..fe8fc4b5bd7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -33,7 +33,7 @@ class MangaBackupCreator( // Entry for this manga val mangaObject = manga.toBackupManga() - mangaObject.excludedScanlators = database.excluded_scanlatorsQueries + mangaObject.excludedScanlators = database.excluded_scanlatorQueries .getExcludedScanlatorsByMangaId(manga.id) .awaitAsList() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 20b2564dc7e..423a431a967 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -421,11 +421,11 @@ class MangaRestorer( */ private suspend fun restoreExcludedScanlators(manga: Manga, excludedScanlators: List) { if (excludedScanlators.isEmpty()) return - val existingExcludedScanlators = database.excluded_scanlatorsQueries + val existingExcludedScanlators = database.excluded_scanlatorQueries .getExcludedScanlatorsByMangaId(manga.id) .awaitAsList() val toInsert = excludedScanlators.filter { it !in existingExcludedScanlators } if (toInsert.isEmpty()) return - toInsert.forEach { database.excluded_scanlatorsQueries.insert(manga.id, it) } + toInsert.forEach { database.excluded_scanlatorQueries.insert(manga.id, it) } } } diff --git a/data/src/main/sqldelight/tachiyomi/data/chapter.sq b/data/src/main/sqldelight/tachiyomi/data/chapter.sq index e28ff5c6cdc..f9372d3bcfb 100644 --- a/data/src/main/sqldelight/tachiyomi/data/chapter.sq +++ b/data/src/main/sqldelight/tachiyomi/data/chapter.sq @@ -38,7 +38,7 @@ WHERE id = :id; getChaptersByMangaId: SELECT C.* FROM chapter C -LEFT JOIN excluded_scanlators ES +LEFT JOIN excluded_scanlator ES ON C.manga_id = ES.manga_id AND C.remote_scanlator = ES.scanlator WHERE C.manga_id = :mangaId diff --git a/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq b/data/src/main/sqldelight/tachiyomi/data/excluded_scanlator.sq similarity index 50% rename from data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq rename to data/src/main/sqldelight/tachiyomi/data/excluded_scanlator.sq index b0a42ff3ec2..114a0f5c389 100644 --- a/data/src/main/sqldelight/tachiyomi/data/excluded_scanlators.sq +++ b/data/src/main/sqldelight/tachiyomi/data/excluded_scanlator.sq @@ -1,23 +1,22 @@ -CREATE TABLE excluded_scanlators( +CREATE TABLE excluded_scanlator( manga_id INTEGER NOT NULL, scanlator TEXT NOT NULL, + UNIQUE(manga_id, scanlator), FOREIGN KEY(manga_id) REFERENCES manga (id) ON DELETE CASCADE ); -CREATE INDEX excluded_scanlators_manga_id_index ON excluded_scanlators(manga_id); -CREATE INDEX idx_excluded_scanlators_scanlator ON excluded_scanlators(scanlator); insert: -INSERT INTO excluded_scanlators(manga_id, scanlator) +INSERT INTO excluded_scanlator(manga_id, scanlator) VALUES (:mangaId, :scanlator); remove: -DELETE FROM excluded_scanlators +DELETE FROM excluded_scanlator WHERE manga_id = :mangaId AND scanlator IN :scanlators; getExcludedScanlatorsByMangaId: SELECT scanlator -FROM excluded_scanlators +FROM excluded_scanlator WHERE manga_id = :mangaId; diff --git a/data/src/main/sqldelight/tachiyomi/data/manga.sq b/data/src/main/sqldelight/tachiyomi/data/manga.sq index 4e4ab063aec..99d182c3c8f 100644 --- a/data/src/main/sqldelight/tachiyomi/data/manga.sq +++ b/data/src/main/sqldelight/tachiyomi/data/manga.sq @@ -122,7 +122,7 @@ chapter_counts AS ( FROM duplicates M JOIN chapter C ON M.id = C.manga_id - LEFT JOIN excluded_scanlators ES + LEFT JOIN excluded_scanlator ES ON C.manga_id = ES.manga_id AND C.remote_scanlator = ES.scanlator WHERE ES.scanlator IS NULL diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index 0d5f3eaa83d..9d558921651 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -365,7 +365,30 @@ DROP TABLE history_temp; CREATE INDEX history_manga_id_index ON history(manga_id); CREATE INDEX history_read_at_index ON history(read_at); --- The views come back reading the renamed columns +-- --------------------------------------------------------------------------------------------------------- +-- table - excluded_scanlator +-- --------------------------------------------------------------------------------------------------------- + +CREATE TABLE excluded_scanlator( + manga_id INTEGER NOT NULL, + scanlator TEXT NOT NULL, + UNIQUE(manga_id, scanlator), + FOREIGN KEY(manga_id) REFERENCES manga (id) + ON DELETE CASCADE +); + +-- Excluding a scanlator twice excludes it once, and merged entries brought their exclusions along, so +-- the copies collapse. `excluded_scanlators_manga_id_index` is a prefix of the new constraint. +INSERT INTO excluded_scanlator(manga_id, scanlator) +SELECT DISTINCT coalesce(D.keep_id, ES.manga_id), ES.scanlator +FROM excluded_scanlators ES +LEFT JOIN duplicate_mangas D +ON D._id = ES.manga_id; + +DROP TABLE excluded_scanlators; + + +-- The views come back reading the new table CREATE VIEW historyView AS SELECT history.id AS id, @@ -415,12 +438,12 @@ LEFT JOIN ( coalesce(max(chapter.state_date_fetch), 0) AS fetchedAt, sum(chapter.user_bookmark) AS bookmarkCount FROM chapter - LEFT JOIN excluded_scanlators - ON chapter.manga_id = excluded_scanlators.manga_id - AND chapter.remote_scanlator = excluded_scanlators.scanlator + LEFT JOIN excluded_scanlator + ON chapter.manga_id = excluded_scanlator.manga_id + AND chapter.remote_scanlator = excluded_scanlator.scanlator LEFT JOIN history ON chapter.id = history.chapter_id - WHERE excluded_scanlators.scanlator IS NULL + WHERE excluded_scanlator.scanlator IS NULL GROUP BY chapter.manga_id ) AS C ON M.id = C.manga_id @@ -449,12 +472,12 @@ SELECT manga.state_cover_last_modified AS coverLastModified, chapter.remote_date_upload AS dateUpload, chapter.state_date_fetch AS datefetch, - excluded_scanlators.scanlator AS excludedScanlator + excluded_scanlator.scanlator AS excludedScanlator FROM manga JOIN chapter ON manga.id = chapter.manga_id -LEFT JOIN excluded_scanlators -ON manga.id = excluded_scanlators.manga_id -AND chapter.remote_scanlator = excluded_scanlators.scanlator +LEFT JOIN excluded_scanlator +ON manga.id = excluded_scanlator.manga_id +AND chapter.remote_scanlator = excluded_scanlator.scanlator WHERE user_favorite_at IS NOT NULL AND state_date_fetch > user_favorite_at ORDER BY state_date_fetch DESC; diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index e65cffa17a9..b11a2f7952a 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -19,12 +19,12 @@ LEFT JOIN ( coalesce(max(chapter.state_date_fetch), 0) AS fetchedAt, sum(chapter.user_bookmark) AS bookmarkCount FROM chapter - LEFT JOIN excluded_scanlators - ON chapter.manga_id = excluded_scanlators.manga_id - AND chapter.remote_scanlator = excluded_scanlators.scanlator + LEFT JOIN excluded_scanlator + ON chapter.manga_id = excluded_scanlator.manga_id + AND chapter.remote_scanlator = excluded_scanlator.scanlator LEFT JOIN history ON chapter.id = history.chapter_id - WHERE excluded_scanlators.scanlator IS NULL + WHERE excluded_scanlator.scanlator IS NULL GROUP BY chapter.manga_id ) AS C ON M.id = C.manga_id diff --git a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq index 24b4af37886..3d2bace9f3a 100644 --- a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq @@ -15,12 +15,12 @@ SELECT manga.state_cover_last_modified AS coverLastModified, chapter.remote_date_upload AS dateUpload, chapter.state_date_fetch AS datefetch, - excluded_scanlators.scanlator AS excludedScanlator + excluded_scanlator.scanlator AS excludedScanlator FROM manga JOIN chapter ON manga.id = chapter.manga_id -LEFT JOIN excluded_scanlators -ON manga.id = excluded_scanlators.manga_id -AND chapter.remote_scanlator = excluded_scanlators.scanlator +LEFT JOIN excluded_scanlator +ON manga.id = excluded_scanlator.manga_id +AND chapter.remote_scanlator = excluded_scanlator.scanlator WHERE user_favorite_at IS NOT NULL AND state_date_fetch > user_favorite_at ORDER BY state_date_fetch DESC; From f55bfe9a5bef21778ef8cb6edc869bd89d26845a Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 17 Aug 2026 23:57:02 +0600 Subject: [PATCH 07/11] table - manga_track Rebuild `manga_sync` as `manga_track`: the table holds tracker entries, and nothing about it is a sync. `_id` -> `id` and `sync_id` -> `tracker_id`, which is what it identifies. `idx_manga_sync_manga_id` goes away because UNIQUE(manga_id, tracker_id) already indexes `manga_id` as its leading column. When entries merge, two rows for the same tracker can end up pointing at the survivor. ON CONFLICT REPLACE would drop one of them silently and keep whichever the copy happened to reach last, so the row to keep is chosen first: the one furthest read, then the one already on the survivor, 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. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../create/creators/MangaBackupCreator.kt | 2 +- .../data/backup/models/BackupTracking.kt | 2 +- .../backup/restore/restorers/MangaRestorer.kt | 2 +- .../java/tachiyomi/data/track/TrackMapper.kt | 2 +- .../data/track/TrackRepositoryImpl.kt | 12 ++-- .../main/sqldelight/tachiyomi/data/manga.sq | 6 +- .../data/{manga_sync.sq => manga_track.sq} | 34 +++++----- .../sqldelight/tachiyomi/migrations/14.sqm | 63 +++++++++++++++++++ 8 files changed, 92 insertions(+), 31 deletions(-) rename data/src/main/sqldelight/tachiyomi/data/{manga_sync.sq => manga_track.sq} (67%) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt index fe8fc4b5bd7..a5942f9a465 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/MangaBackupCreator.kt @@ -59,7 +59,7 @@ class MangaBackupCreator( } if (options.tracking) { - val tracks = database.manga_syncQueries + val tracks = database.manga_trackQueries .getTracksByMangaId(manga.id, backupTrackMapper) .awaitAsList() if (tracks.isNotEmpty()) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt index 56108e68e8b..4d8e48eb0ef 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupTracking.kt @@ -59,13 +59,13 @@ val backupTrackMapper = { _: Long, syncId: Long, mediaId: Long, + remoteUrl: String, libraryId: Long?, title: String, lastChapterRead: Double, totalChapters: Long, status: Long, score: Double, - remoteUrl: String, startDate: Long, finishDate: Long, private: Boolean, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt index 423a431a967..fd95fefcd10 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/MangaRestorer.kt @@ -391,7 +391,7 @@ class MangaRestorer( if (existingTracks.isEmpty()) return database.transaction { existingTracks.forEach { track -> - database.manga_syncQueries.update( + database.manga_trackQueries.update( track.mangaId, track.trackerId, track.remoteId, diff --git a/data/src/main/java/tachiyomi/data/track/TrackMapper.kt b/data/src/main/java/tachiyomi/data/track/TrackMapper.kt index 83a3f5b8660..d75b9ebe832 100644 --- a/data/src/main/java/tachiyomi/data/track/TrackMapper.kt +++ b/data/src/main/java/tachiyomi/data/track/TrackMapper.kt @@ -8,13 +8,13 @@ object TrackMapper { mangaId: Long, syncId: Long, remoteId: Long, + remoteUrl: String, libraryId: Long?, title: String, lastChapterRead: Double, totalChapters: Long, status: Long, score: Double, - remoteUrl: String, startDate: Long, finishDate: Long, private: Boolean, diff --git a/data/src/main/java/tachiyomi/data/track/TrackRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/track/TrackRepositoryImpl.kt index befd963243a..4b3c1b6c769 100644 --- a/data/src/main/java/tachiyomi/data/track/TrackRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/track/TrackRepositoryImpl.kt @@ -20,31 +20,31 @@ class TrackRepositoryImpl( ) : TrackRepository { override suspend fun getTrackById(id: Long): Track? { - return database.manga_syncQueries + return database.manga_trackQueries .getTrackById(id, TrackMapper::mapTrack) .awaitAsOneOrNull() } override suspend fun getTracksByMangaId(mangaId: Long): List { - return database.manga_syncQueries + return database.manga_trackQueries .getTracksByMangaId(mangaId, TrackMapper::mapTrack) .awaitAsList() } override fun getTracksAsFlow(): Flow> { - return database.manga_syncQueries + return database.manga_trackQueries .getTracks(TrackMapper::mapTrack) .subscribeToList() } override fun getTracksByMangaIdAsFlow(mangaId: Long): Flow> { - return database.manga_syncQueries + return database.manga_trackQueries .getTracksByMangaId(mangaId, TrackMapper::mapTrack) .subscribeToList() } override suspend fun delete(mangaId: Long, trackerId: Long) { - database.manga_syncQueries.delete( + database.manga_trackQueries.delete( mangaId = mangaId, syncId = trackerId, ) @@ -61,7 +61,7 @@ class TrackRepositoryImpl( private suspend fun insertValues(vararg tracks: Track) { database.transaction { tracks.forEach { mangaTrack -> - database.manga_syncQueries.insert( + database.manga_trackQueries.insert( mangaId = mangaTrack.mangaId, syncId = mangaTrack.trackerId, remoteId = mangaTrack.remoteId, diff --git a/data/src/main/sqldelight/tachiyomi/data/manga.sq b/data/src/main/sqldelight/tachiyomi/data/manga.sq index 99d182c3c8f..2c397a921a2 100644 --- a/data/src/main/sqldelight/tachiyomi/data/manga.sq +++ b/data/src/main/sqldelight/tachiyomi/data/manga.sq @@ -99,9 +99,9 @@ getDuplicateLibraryManga: WITH track_dupes AS ( SELECT DISTINCT S2.manga_id - FROM manga_sync S1 - INNER JOIN manga_sync S2 - ON S1.sync_id = S2.sync_id + FROM manga_track S1 + INNER JOIN manga_track S2 + ON S1.tracker_id = S2.tracker_id AND S1.remote_id = S2.remote_id AND S1.manga_id != S2.manga_id WHERE S1.manga_id = :id diff --git a/data/src/main/sqldelight/tachiyomi/data/manga_sync.sq b/data/src/main/sqldelight/tachiyomi/data/manga_track.sq similarity index 67% rename from data/src/main/sqldelight/tachiyomi/data/manga_sync.sq rename to data/src/main/sqldelight/tachiyomi/data/manga_track.sq index d3007fbb104..687142c96da 100644 --- a/data/src/main/sqldelight/tachiyomi/data/manga_sync.sq +++ b/data/src/main/sqldelight/tachiyomi/data/manga_track.sq @@ -1,54 +1,52 @@ import kotlin.Boolean; -CREATE TABLE manga_sync( - _id INTEGER NOT NULL PRIMARY KEY, +CREATE TABLE manga_track( + id INTEGER NOT NULL PRIMARY KEY, manga_id INTEGER NOT NULL, - sync_id INTEGER NOT NULL, + tracker_id INTEGER NOT NULL, remote_id INTEGER NOT NULL, + remote_url TEXT NOT NULL, library_id INTEGER, title TEXT NOT NULL, last_chapter_read REAL NOT NULL, total_chapters INTEGER NOT NULL, status INTEGER NOT NULL, score REAL NOT NULL, - remote_url TEXT NOT NULL, start_date INTEGER NOT NULL, finish_date INTEGER NOT NULL, - private INTEGER AS Boolean DEFAULT 0 NOT NULL, - UNIQUE (manga_id, sync_id) ON CONFLICT REPLACE, + private INTEGER AS Boolean NOT NULL DEFAULT 0, + UNIQUE (manga_id, tracker_id) ON CONFLICT REPLACE, FOREIGN KEY(manga_id) REFERENCES manga (id) ON DELETE CASCADE ); -CREATE INDEX idx_manga_sync_manga_id ON manga_sync(manga_id); - delete: -DELETE FROM manga_sync -WHERE manga_id = :mangaId AND sync_id = :syncId; +DELETE FROM manga_track +WHERE manga_id = :mangaId AND tracker_id = :syncId; getTracks: SELECT * -FROM manga_sync; +FROM manga_track; getTrackById: SELECT * -FROM manga_sync -WHERE _id = :id; +FROM manga_track +WHERE id = :id; getTracksByMangaId: SELECT * -FROM manga_sync +FROM manga_track WHERE manga_id = :mangaId; insert: -INSERT INTO manga_sync(manga_id,sync_id,remote_id,library_id,title,last_chapter_read,total_chapters,status,score,remote_url,start_date,finish_date,private) +INSERT INTO manga_track(manga_id,tracker_id,remote_id,library_id,title,last_chapter_read,total_chapters,status,score,remote_url,start_date,finish_date,private) VALUES (:mangaId,:syncId,:remoteId,:libraryId,:title,:lastChapterRead,:totalChapters,:status,:score,:remoteUrl,:startDate,:finishDate,:private); update: -UPDATE manga_sync +UPDATE manga_track SET manga_id = coalesce(:mangaId, manga_id), - sync_id = coalesce(:syncId, sync_id), + tracker_id = coalesce(:syncId, tracker_id), remote_id = coalesce(:mediaId, remote_id), library_id = coalesce(:libraryId, library_id), title = coalesce(:title, title), @@ -60,4 +58,4 @@ SET start_date = coalesce(:startDate, start_date), finish_date = coalesce(:finishDate, finish_date), private = coalesce(:private, private) -WHERE _id = :id; +WHERE id = :id; diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index 9d558921651..dbb7c226f27 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -388,6 +388,69 @@ ON D._id = ES.manga_id; DROP TABLE excluded_scanlators; +-- --------------------------------------------------------------------------------------------------------- +-- table - manga_track +-- --------------------------------------------------------------------------------------------------------- + +-- `manga_sync` is what tracking was called when it was still called syncing; `sync_id` is the tracker. +-- ON CONFLICT REPLACE stays: InsertTrack relies on it to upsert a tracker's entry for a manga. That +-- also means the copy below would silently drop a row when an entry that merged left two rows for the +-- same tracker pointing at the survivor, so the one to keep is chosen first: the one furthest read, +-- then the one already on the survivor, 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 + ORDER BY T.last_chapter_read DESC, (T.manga_id = D.keep_id) DESC, T._id ASC + ) AS keep_track_id +FROM manga_sync T +JOIN duplicate_mangas D +ON D._id = T.manga_id; + +DELETE FROM manga_sync +WHERE _id IN (SELECT _id FROM duplicate_tracks WHERE _id != keep_track_id); + +DROP TABLE duplicate_tracks; + +CREATE TABLE manga_track( + id INTEGER NOT NULL PRIMARY KEY, + manga_id INTEGER NOT NULL, + tracker_id INTEGER NOT NULL, + remote_id INTEGER NOT NULL, + remote_url TEXT NOT NULL, + library_id INTEGER, + title TEXT NOT NULL, + last_chapter_read REAL NOT NULL, + total_chapters INTEGER NOT NULL, + status INTEGER NOT NULL, + score REAL NOT NULL, + start_date INTEGER NOT NULL, + finish_date INTEGER NOT NULL, + private INTEGER NOT NULL DEFAULT 0, + UNIQUE (manga_id, tracker_id) ON CONFLICT REPLACE, + FOREIGN KEY(manga_id) REFERENCES manga (id) + ON DELETE CASCADE +); + +INSERT INTO manga_track( + id, manga_id, tracker_id, remote_id, remote_url, library_id, title, last_chapter_read, + total_chapters, status, score, start_date, finish_date, private +) +SELECT + T._id, coalesce(D.keep_id, T.manga_id), T.sync_id, T.remote_id, T.remote_url, T.library_id, + T.title, T.last_chapter_read, T.total_chapters, T.status, T.score, T.start_date, + T.finish_date, T.private +FROM manga_sync T +LEFT JOIN duplicate_mangas D +ON D._id = T.manga_id; + +DROP TABLE duplicate_mangas; + +-- `idx_manga_sync_manga_id` is a prefix of the unique constraint +DROP TABLE manga_sync; + -- The views come back reading the new table CREATE VIEW historyView AS SELECT From 229a30577530e052efe79472d577c170216be419 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:45:27 +0600 Subject: [PATCH 08/11] table - source Rebuild `sources` as `source` with `_id` -> `id` and `lang` -> `language`, spelled out like every other column here. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../data/source/StubSourceRepositoryImpl.kt | 6 ++--- .../main/sqldelight/tachiyomi/data/source.sq | 24 +++++++++++++++++++ .../main/sqldelight/tachiyomi/data/sources.sq | 24 ------------------- .../sqldelight/tachiyomi/migrations/14.sqm | 18 ++++++++++++++ 4 files changed, 45 insertions(+), 27 deletions(-) create mode 100644 data/src/main/sqldelight/tachiyomi/data/source.sq delete mode 100644 data/src/main/sqldelight/tachiyomi/data/sources.sq diff --git a/data/src/main/java/tachiyomi/data/source/StubSourceRepositoryImpl.kt b/data/src/main/java/tachiyomi/data/source/StubSourceRepositoryImpl.kt index 8aee2e36fd8..aef9cdda294 100644 --- a/data/src/main/java/tachiyomi/data/source/StubSourceRepositoryImpl.kt +++ b/data/src/main/java/tachiyomi/data/source/StubSourceRepositoryImpl.kt @@ -19,19 +19,19 @@ class StubSourceRepositoryImpl( ) : StubSourceRepository { override fun subscribeAll(): Flow> { - return database.sourcesQueries + return database.sourceQueries .findAll(::mapStubSource) .subscribeToList() } override suspend fun getStubSource(id: Long): StubSource? { - return database.sourcesQueries + return database.sourceQueries .findOne(id, ::mapStubSource) .awaitAsOneOrNull() } override suspend fun upsertStubSource(id: Long, lang: String, name: String) { - database.sourcesQueries.upsert(id, lang, name) + database.sourceQueries.upsert(id, lang, name) } private fun mapStubSource( diff --git a/data/src/main/sqldelight/tachiyomi/data/source.sq b/data/src/main/sqldelight/tachiyomi/data/source.sq new file mode 100644 index 00000000000..e7ddd7ab6e8 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/data/source.sq @@ -0,0 +1,24 @@ +CREATE TABLE source( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + language TEXT NOT NULL +); + +findAll: +SELECT * +FROM source; + +findOne: +SELECT * +FROM source +WHERE id = :id; + +upsert: +INSERT INTO source(id, language, name) +VALUES (:id, :lang, :name) +ON CONFLICT(id) +DO UPDATE +SET + language = :lang, + name = :name +WHERE id = :id; diff --git a/data/src/main/sqldelight/tachiyomi/data/sources.sq b/data/src/main/sqldelight/tachiyomi/data/sources.sq deleted file mode 100644 index aac60bb7898..00000000000 --- a/data/src/main/sqldelight/tachiyomi/data/sources.sq +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE sources( - _id INTEGER NOT NULL PRIMARY KEY, - lang TEXT NOT NULL, - name TEXT NOT NULL -); - -findAll: -SELECT * -FROM sources; - -findOne: -SELECT * -FROM sources -WHERE _id = :id; - -upsert: -INSERT INTO sources(_id, lang, name) -VALUES (:id, :lang, :name) -ON CONFLICT(_id) -DO UPDATE -SET - lang = :lang, - name = :name -WHERE _id = :id; \ No newline at end of file diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index dbb7c226f27..e3a2a49685c 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -451,6 +451,24 @@ DROP TABLE duplicate_mangas; -- `idx_manga_sync_manga_id` is a prefix of the unique constraint DROP TABLE manga_sync; +-- --------------------------------------------------------------------------------------------------------- +-- table - source +-- --------------------------------------------------------------------------------------------------------- + +-- The last of the plural table names, and `lang` spelled out. Nothing references this table, so it is a +-- straight copy; name comes before language to read the way the row does. +CREATE TABLE source( + id INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + language TEXT NOT NULL +); + +INSERT INTO source(id, name, language) +SELECT _id, name, lang +FROM sources; + +DROP TABLE sources; + -- The views come back reading the new table CREATE VIEW historyView AS SELECT From 60d40fba3c7334a5fabfe67501cf96d970cf31dd Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:46:31 +0600 Subject: [PATCH 09/11] view - updates The view is the Kotlin-facing shape of a row, so its columns are named the way the callers read them; `last_page_read` and `datefetch` were the only two that weren't. The migration builds every view once at its end, so this updates that definition rather than dropping and recreating it again. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- data/src/main/sqldelight/tachiyomi/migrations/14.sqm | 6 +++--- data/src/main/sqldelight/tachiyomi/view/updatesView.sq | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index e3a2a49685c..cd7766aa12c 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -546,13 +546,13 @@ SELECT chapter.remote_url AS chapterUrl, chapter.user_read AS read, chapter.user_bookmark AS bookmark, - chapter.user_last_page_read AS last_page_read, - manga.source_id AS source, + chapter.user_last_page_read AS lastPageRead, + manga.source_id AS sourceId, manga.user_favorite_at AS favoriteAt, manga.remote_cover AS thumbnailUrl, manga.state_cover_last_modified AS coverLastModified, chapter.remote_date_upload AS dateUpload, - chapter.state_date_fetch AS datefetch, + chapter.state_date_fetch AS dateFetch, excluded_scanlator.scanlator AS excludedScanlator FROM manga JOIN chapter ON manga.id = chapter.manga_id diff --git a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq index 3d2bace9f3a..a20bad97d31 100644 --- a/data/src/main/sqldelight/tachiyomi/view/updatesView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/updatesView.sq @@ -8,13 +8,13 @@ SELECT chapter.remote_url AS chapterUrl, chapter.user_read AS read, chapter.user_bookmark AS bookmark, - chapter.user_last_page_read AS last_page_read, - manga.source_id AS source, + chapter.user_last_page_read AS lastPageRead, + manga.source_id AS sourceId, manga.user_favorite_at AS favoriteAt, manga.remote_cover AS thumbnailUrl, manga.state_cover_last_modified AS coverLastModified, chapter.remote_date_upload AS dateUpload, - chapter.state_date_fetch AS datefetch, + chapter.state_date_fetch AS dateFetch, excluded_scanlator.scanlator AS excludedScanlator FROM manga JOIN chapter ON manga.id = chapter.manga_id @@ -39,8 +39,8 @@ AND (:read IS NULL OR read = :read) -- Started means some progress but not finished, Read means finished chapter, thus: AND ( :started IS NULL - OR (:started = 1 AND last_page_read > 0 AND read = 0) - OR (:started = 0 AND last_page_read = 0 AND read = 0) + OR (:started = 1 AND lastPageRead > 0 AND read = 0) + OR (:started = 0 AND lastPageRead = 0 AND read = 0) ) AND (:bookmarked IS NULL OR bookmark = :bookmarked) AND ( From 6e0530853acb2bb625b686cf7bfaa99c1bdb9acb Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Tue, 18 Aug 2026 00:18:56 +0600 Subject: [PATCH 10/11] view - library Bound the chapter and category aggregates to the library. Both subqueries group over every chapter and every category membership in the database, and only the outer query drops the entries that are not in the library, so an entry that was merely browsed still pays for its chapters every time the library is read. The entries the view keeps are the only ones whose aggregates it uses, so the same filter belongs inside them, where `manga_user_favorite_at_index` can serve it. The view returns the same rows either way. On a 560k chapter database holding 400 entries among 4400, reading the library goes from ~156ms to ~23ms. Assisted-by: Claude:claude-opus-5 --- data/src/main/sqldelight/tachiyomi/migrations/14.sqm | 2 ++ data/src/main/sqldelight/tachiyomi/view/libraryView.sq | 2 ++ 2 files changed, 4 insertions(+) diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index cd7766aa12c..176281b27b1 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -525,12 +525,14 @@ LEFT JOIN ( LEFT JOIN history ON chapter.id = history.chapter_id WHERE excluded_scanlator.scanlator IS NULL + AND chapter.manga_id IN (SELECT id FROM manga WHERE user_favorite_at IS NOT NULL) GROUP BY chapter.manga_id ) AS C ON M.id = C.manga_id LEFT JOIN ( SELECT manga_id, group_concat(category_id) AS categories FROM manga_category + WHERE manga_id IN (SELECT id FROM manga WHERE user_favorite_at IS NOT NULL) GROUP BY manga_id ) AS MC ON MC.manga_id = M.id diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index b11a2f7952a..bf22a3bd847 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -25,12 +25,14 @@ LEFT JOIN ( LEFT JOIN history ON chapter.id = history.chapter_id WHERE excluded_scanlator.scanlator IS NULL + AND chapter.manga_id IN (SELECT id FROM manga WHERE user_favorite_at IS NOT NULL) GROUP BY chapter.manga_id ) AS C ON M.id = C.manga_id LEFT JOIN ( SELECT manga_id, group_concat(category_id) AS categories FROM manga_category + WHERE manga_id IN (SELECT id FROM manga WHERE user_favorite_at IS NOT NULL) GROUP BY manga_id ) AS MC ON MC.manga_id = M.id From caec73dcfa58466b4045d1a8b5483162eccbfc7b Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Mon, 17 Aug 2026 22:47:38 +0600 Subject: [PATCH 11/11] view - history The view is the Kotlin-facing shape of a row, so its columns are named the way the callers read them: `source` becomes `sourceId` and `state_cover_last_modified` loses the prefix it only carried because the column it reads has one. The migration builds every view once at its end, so this updates that definition rather than dropping and recreating it again. Queries are only retargeted at the new names; using the new structure is left to later changes. Assisted-by: Claude:claude-opus-5 --- .../sqldelight/tachiyomi/migrations/14.sqm | 12 +++++------ .../sqldelight/tachiyomi/view/historyView.sq | 20 +++++++++---------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm index 176281b27b1..3c3d126e3e4 100644 --- a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -477,14 +477,14 @@ SELECT chapter.id AS chapterId, manga.remote_title AS title, manga.remote_cover AS thumbnailUrl, - manga.source_id AS source, + manga.source_id AS sourceId, manga.user_favorite_at AS favoriteAt, - manga.state_cover_last_modified, + manga.state_cover_last_modified AS coverLastModified, chapter.remote_chapter_number AS chapterNumber, history.read_at AS readAt, history.read_duration AS readDuration, - max_last_read.read_at AS maxReadAt, - max_last_read.chapter_id AS maxReadAtChapterId + max_read.read_at AS maxReadAt, + max_read.chapter_id AS maxReadAtChapterId FROM manga JOIN chapter ON manga.id = chapter.manga_id @@ -495,8 +495,8 @@ JOIN ( FROM chapter JOIN history ON chapter.id = history.chapter_id GROUP BY chapter.manga_id -) AS max_last_read -ON chapter.manga_id = max_last_read.manga_id; +) AS max_read +ON chapter.manga_id = max_read.manga_id; CREATE VIEW libraryView AS SELECT diff --git a/data/src/main/sqldelight/tachiyomi/view/historyView.sq b/data/src/main/sqldelight/tachiyomi/view/historyView.sq index 412f755a96c..5de9f624c0b 100644 --- a/data/src/main/sqldelight/tachiyomi/view/historyView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/historyView.sq @@ -5,14 +5,14 @@ SELECT chapter.id AS chapterId, manga.remote_title AS title, manga.remote_cover AS thumbnailUrl, - manga.source_id AS source, + manga.source_id AS sourceId, manga.user_favorite_at AS favoriteAt, - manga.state_cover_last_modified, + manga.state_cover_last_modified AS coverLastModified, chapter.remote_chapter_number AS chapterNumber, history.read_at AS readAt, history.read_duration AS readDuration, - max_last_read.read_at AS maxReadAt, - max_last_read.chapter_id AS maxReadAtChapterId + max_read.read_at AS maxReadAt, + max_read.chapter_id AS maxReadAtChapterId FROM manga JOIN chapter ON manga.id = chapter.manga_id @@ -23,8 +23,8 @@ JOIN ( FROM chapter JOIN history ON chapter.id = history.chapter_id GROUP BY chapter.manga_id -) AS max_last_read -ON chapter.manga_id = max_last_read.manga_id; +) AS max_read +ON chapter.manga_id = max_read.manga_id; history: SELECT @@ -33,9 +33,9 @@ mangaId, chapterId, title, thumbnailUrl, -source, +sourceId, favoriteAt, -state_cover_last_modified, +coverLastModified, chapterNumber, readAt, readDuration @@ -52,9 +52,9 @@ mangaId, chapterId, title, thumbnailUrl, -source, +sourceId, favoriteAt, -state_cover_last_modified, +coverLastModified, chapterNumber, readAt, readDuration