Feature/backup tracking (#940)

* Include tracking in validation of backup

* Always return track records

Not clear why an empty list should be returned in case no trackers are logged in

* Include tracking in backup creation

* Restore tracking from backup
This commit is contained in:
schroda
2024-05-05 19:24:16 +02:00
committed by GitHub
parent cf1ede9cf7
commit 7df5f1c4c4
9 changed files with 294 additions and 158 deletions

View File

@@ -16,14 +16,20 @@ class BackupQuery {
val name: String, val name: String,
) )
data class ValidateBackupTracker(
val name: String,
)
data class ValidateBackupResult( data class ValidateBackupResult(
val missingSources: List<ValidateBackupSource>, val missingSources: List<ValidateBackupSource>,
val missingTrackers: List<ValidateBackupTracker>,
) )
fun validateBackup(input: ValidateBackupInput): ValidateBackupResult { fun validateBackup(input: ValidateBackupInput): ValidateBackupResult {
val result = ProtoBackupValidator.validate(input.backup.content) val result = ProtoBackupValidator.validate(input.backup.content)
return ValidateBackupResult( return ValidateBackupResult(
result.missingSourceIds.map { ValidateBackupSource(it.first, it.second) }, result.missingSourceIds.map { ValidateBackupSource(it.first, it.second) },
result.missingTrackers.map { ValidateBackupTracker(it) },
) )
} }

View File

@@ -11,7 +11,7 @@ interface Track : Serializable {
var sync_id: Int var sync_id: Int
var media_id: Int var media_id: Long
var library_id: Long? var library_id: Long?

View File

@@ -9,7 +9,7 @@ class TrackImpl : Track {
override var sync_id: Int = 0 override var sync_id: Int = 0
override var media_id: Int = 0 override var media_id: Long = 0L
override var library_id: Long? = null override var library_id: Long? = null
@@ -43,7 +43,7 @@ class TrackImpl : Track {
override fun hashCode(): Int { override fun hashCode(): Int {
var result = (manga_id xor manga_id.ushr(32)).toInt() var result = (manga_id xor manga_id.ushr(32)).toInt()
result = 31 * result + sync_id result = 31 * result + sync_id
result = 31 * result + media_id result = (31 * result + media_id).toInt()
return result return result
} }
} }

View File

@@ -31,6 +31,8 @@ import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupChapter
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSource
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.Track
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaStatus import suwayomi.tachidesk.manga.model.table.MangaStatus
@@ -230,9 +232,32 @@ object ProtoBackupExport : ProtoBackupBase() {
backupManga.categories = CategoryManga.getMangaCategories(mangaId).map { it.order } backupManga.categories = CategoryManga.getMangaCategories(mangaId).map { it.order }
} }
// if(flags.includeTracking) { if (flags.includeTracking) {
// backupManga.tracking = TODO() val tracks =
// } Track.getTrackRecordsByMangaId(mangaRow[MangaTable.id].value).mapNotNull {
if (it.record == null) {
null
} else {
BackupTracking(
syncId = it.record.trackerId,
// forced not null so its compatible with 1.x backup system
libraryId = it.record.libraryId ?: 0,
mediaId = it.record.remoteId,
title = it.record.title,
lastChapterRead = it.record.lastChapterRead.toFloat(),
totalChapters = it.record.totalChapters,
score = it.record.score.toFloat(),
status = it.record.status,
startedReadingDate = it.record.startDate,
finishedReadingDate = it.record.finishDate,
trackingUrl = it.record.remoteUrl,
)
}
}
if (tracks.isNotEmpty()) {
backupManga.tracking = tracks
}
}
// if (flags.includeHistory) { // if (flags.includeHistory) {
// backupManga.history = TODO() // backupManga.history = TODO()

View File

@@ -34,22 +34,26 @@ import suwayomi.tachidesk.manga.impl.CategoryManga
import suwayomi.tachidesk.manga.impl.Manga.clearThumbnail import suwayomi.tachidesk.manga.impl.Manga.clearThumbnail
import suwayomi.tachidesk.manga.impl.backup.models.Chapter import suwayomi.tachidesk.manga.impl.backup.models.Chapter
import suwayomi.tachidesk.manga.impl.backup.models.Manga import suwayomi.tachidesk.manga.impl.backup.models.Manga
import suwayomi.tachidesk.manga.impl.backup.models.Track
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.ValidationResult
import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.validate import suwayomi.tachidesk.manga.impl.backup.proto.ProtoBackupValidator.validate
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupCategory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupHistory
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupManga
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrackRecordDataClass
import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
import suwayomi.tachidesk.manga.model.table.CategoryTable import suwayomi.tachidesk.manga.model.table.CategoryTable
import suwayomi.tachidesk.manga.model.table.ChapterTable import suwayomi.tachidesk.manga.model.table.ChapterTable
import suwayomi.tachidesk.manga.model.table.MangaTable import suwayomi.tachidesk.manga.model.table.MangaTable
import java.io.InputStream import java.io.InputStream
import java.lang.Integer.max
import java.util.Date import java.util.Date
import java.util.Timer import java.util.Timer
import java.util.TimerTask import java.util.TimerTask
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.math.max
import suwayomi.tachidesk.manga.impl.track.Track as Tracker
object ProtoBackupImport : ProtoBackupBase() { object ProtoBackupImport : ProtoBackupBase() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@@ -239,10 +243,9 @@ object ProtoBackupImport : ProtoBackupBase() {
val chapters = backupManga.getChaptersImpl() val chapters = backupManga.getChaptersImpl()
val categories = backupManga.categories val categories = backupManga.categories
val history = backupManga.brokenHistory.map { BackupHistory(it.url, it.lastRead) } + backupManga.history val history = backupManga.brokenHistory.map { BackupHistory(it.url, it.lastRead) } + backupManga.history
val tracks = backupManga.getTrackingImpl()
try { try {
restoreMangaData(manga, chapters, categories, history, tracks, backupCategories, categoryMapping) restoreMangaData(manga, chapters, categories, history, backupManga.tracking, backupCategories, categoryMapping)
} catch (e: Exception) { } catch (e: Exception) {
val sourceName = sourceMapping[manga.source] ?: manga.source.toString() val sourceName = sourceMapping[manga.source] ?: manga.source.toString()
errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}") errors.add(Date() to "${manga.title} [$sourceName]: ${e.message}")
@@ -255,7 +258,7 @@ object ProtoBackupImport : ProtoBackupBase() {
chapters: List<Chapter>, chapters: List<Chapter>,
categories: List<Int>, categories: List<Int>,
history: List<BackupHistory>, history: List<BackupHistory>,
tracks: List<Track>, tracks: List<BackupTracking>,
backupCategories: List<BackupCategory>, backupCategories: List<BackupCategory>,
categoryMapping: Map<Int, Int>, categoryMapping: Map<Int, Int>,
) { ) {
@@ -265,6 +268,7 @@ object ProtoBackupImport : ProtoBackupBase() {
.firstOrNull() .firstOrNull()
} }
val mangaId =
if (dbManga == null) { // Manga not in database if (dbManga == null) { // Manga not in database
transaction { transaction {
// insert manga to database // insert manga to database
@@ -320,6 +324,8 @@ object ProtoBackupImport : ProtoBackupBase() {
categories.forEach { backupCategoryOrder -> categories.forEach { backupCategoryOrder ->
CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!) CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!)
} }
mangaId
} }
} else { // Manga in database } else { // Manga in database
transaction { transaction {
@@ -381,11 +387,40 @@ object ProtoBackupImport : ProtoBackupBase() {
categories.forEach { backupCategoryOrder -> categories.forEach { backupCategoryOrder ->
CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!) CategoryManga.addMangaToCategory(mangaId, categoryMapping[backupCategoryOrder]!!)
} }
mangaId
} }
} }
val dbTrackRecordsByTrackerId =
Tracker.getTrackRecordsByMangaId(mangaId)
.mapNotNull { it.record?.toTrack() }
.associateBy { it.sync_id }
val (existingTracks, newTracks) =
tracks.mapNotNull { backupTrack ->
val track = backupTrack.toTrack(mangaId)
val dbTrack =
dbTrackRecordsByTrackerId[backupTrack.syncId]
?: // new track
return@mapNotNull track
if (track.toTrackRecordDataClass().forComparison() == dbTrack.toTrackRecordDataClass().forComparison()) {
return@mapNotNull null
}
dbTrack.also {
it.media_id = track.media_id
it.library_id = track.library_id
it.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
}
}.partition { (it.id ?: -1) > 0 }
existingTracks.forEach(Tracker::updateTrackRecord)
newTracks.forEach(Tracker::insertTrackRecord)
// TODO: insert/merge history // TODO: insert/merge history
}
// TODO: insert/merge tracking private fun TrackRecordDataClass.forComparison() = this.copy(id = 0, mangaId = 0)
}
} }

View File

@@ -15,6 +15,7 @@ import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup import suwayomi.tachidesk.manga.impl.backup.proto.models.Backup
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupSerializer
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.model.table.SourceTable import suwayomi.tachidesk.manga.model.table.SourceTable
import java.io.InputStream import java.io.InputStream
@@ -39,17 +40,18 @@ object ProtoBackupValidator {
sources.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null } sources.filter { SourceTable.select { SourceTable.id eq it.key }.firstOrNull() == null }
} }
// val trackers = backup.backupManga val trackers =
// .flatMap { it.tracking } backup.backupManga
// .map { it.syncId } .flatMap { it.tracking }
// .distinct() .map { it.syncId }
.distinct()
val missingTrackers = listOf("") val missingTrackers =
// val missingTrackers = trackers trackers
// .mapNotNull { trackManager.getService(it) } .mapNotNull { TrackerManager.getTracker(it) }
// .filter { !it.isLogged } .filter { !it.isLoggedIn }
// .map { context.getString(it.nameRes()) } .map { it.name }
// .sorted() .sorted()
return ValidationResult( return ValidationResult(
missingSources missingSources

View File

@@ -8,11 +8,12 @@ import suwayomi.tachidesk.manga.impl.backup.models.TrackImpl
@Serializable @Serializable
data class BackupTracking( data class BackupTracking(
// in 1.x some of these values have different types or names // in 1.x some of these values have different types or names
// syncId is called siteId in 1,x
@ProtoNumber(1) var syncId: Int, @ProtoNumber(1) var syncId: Int,
// LibraryId is not null in 1.x // LibraryId is not null in 1.x
@ProtoNumber(2) var libraryId: Long, @ProtoNumber(2) var libraryId: Long,
@ProtoNumber(3) var mediaId: Int = 0, @Deprecated("Use mediaId instead", level = DeprecationLevel.WARNING)
@ProtoNumber(3)
var mediaIdInt: Int = 0,
// trackingUrl is called mediaUrl in 1.x // trackingUrl is called mediaUrl in 1.x
@ProtoNumber(4) var trackingUrl: String = "", @ProtoNumber(4) var trackingUrl: String = "",
@ProtoNumber(5) var title: String = "", @ProtoNumber(5) var title: String = "",
@@ -25,11 +26,17 @@ data class BackupTracking(
@ProtoNumber(10) var startedReadingDate: Long = 0, @ProtoNumber(10) var startedReadingDate: Long = 0,
// finishedReadingDate is called endReadTime in 1.x // finishedReadingDate is called endReadTime in 1.x
@ProtoNumber(11) var finishedReadingDate: Long = 0, @ProtoNumber(11) var finishedReadingDate: Long = 0,
@ProtoNumber(100) var mediaId: Long = 0,
) { ) {
fun getTrackingImpl(): TrackImpl { fun getTrackingImpl(): TrackImpl {
return TrackImpl().apply { return TrackImpl().apply {
sync_id = this@BackupTracking.syncId sync_id = this@BackupTracking.syncId
media_id = this@BackupTracking.mediaId media_id =
if (this@BackupTracking.mediaIdInt != 0) {
this@BackupTracking.mediaIdInt.toLong()
} else {
this@BackupTracking.mediaId
}
library_id = this@BackupTracking.libraryId library_id = this@BackupTracking.libraryId
title = this@BackupTracking.title title = this@BackupTracking.title
// convert from float to int because of 1.x types // convert from float to int because of 1.x types

View File

@@ -74,9 +74,6 @@ object Track {
} }
fun getTrackRecordsByMangaId(mangaId: Int): List<MangaTrackerDataClass> { fun getTrackRecordsByMangaId(mangaId: Int): List<MangaTrackerDataClass> {
if (!TrackerManager.hasLoggedTracker()) {
return emptyList()
}
val recordMap = val recordMap =
transaction { transaction {
TrackRecordTable.select { TrackRecordTable.mangaId eq mangaId } TrackRecordTable.select { TrackRecordTable.mangaId eq mangaId }
@@ -342,7 +339,7 @@ object Track {
} }
} }
private fun upsertTrackRecord(track: Track): Int { fun upsertTrackRecord(track: Track): Int {
return transaction { return transaction {
val existingRecord = val existingRecord =
TrackRecordTable.select { TrackRecordTable.select {
@@ -352,10 +349,22 @@ object Track {
.singleOrNull() .singleOrNull()
if (existingRecord != null) { if (existingRecord != null) {
TrackRecordTable.update({ updateTrackRecord(track)
existingRecord[TrackRecordTable.id].value
} else {
insertTrackRecord(track)
}
}
}
fun updateTrackRecord(track: Track): Int =
transaction {
TrackRecordTable.update(
{
(TrackRecordTable.mangaId eq track.manga_id) and (TrackRecordTable.mangaId eq track.manga_id) and
(TrackRecordTable.trackerId eq track.sync_id) (TrackRecordTable.trackerId eq track.sync_id)
}) { },
) {
it[remoteId] = track.media_id it[remoteId] = track.media_id
it[libraryId] = track.library_id it[libraryId] = track.library_id
it[title] = track.title it[title] = track.title
@@ -367,8 +376,10 @@ object Track {
it[startDate] = track.started_reading_date it[startDate] = track.started_reading_date
it[finishDate] = track.finished_reading_date it[finishDate] = track.finished_reading_date
} }
existingRecord[TrackRecordTable.id].value }
} else {
fun insertTrackRecord(track: Track): Int =
transaction {
TrackRecordTable.insertAndGetId { TrackRecordTable.insertAndGetId {
it[mangaId] = track.manga_id it[mangaId] = track.manga_id
it[trackerId] = track.sync_id it[trackerId] = track.sync_id
@@ -384,8 +395,6 @@ object Track {
it[finishDate] = track.finished_reading_date it[finishDate] = track.finished_reading_date
}.value }.value
} }
}
}
@Serializable @Serializable
data class LoginInput( data class LoginInput(

View File

@@ -1,8 +1,11 @@
package suwayomi.tachidesk.manga.impl.track.tracker.model package suwayomi.tachidesk.manga.impl.track.tracker.model
import org.jetbrains.exposed.sql.ResultRow import org.jetbrains.exposed.sql.ResultRow
import suwayomi.tachidesk.manga.impl.backup.proto.models.BackupTracking
import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
import suwayomi.tachidesk.manga.model.table.TrackRecordTable import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.lastChapterRead
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl
fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass = fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass =
TrackRecordDataClass( TrackRecordDataClass(
@@ -36,3 +39,52 @@ fun ResultRow.toTrack(): Track =
it.started_reading_date = this[TrackRecordTable.startDate] it.started_reading_date = this[TrackRecordTable.startDate]
it.finished_reading_date = this[TrackRecordTable.finishDate] it.finished_reading_date = this[TrackRecordTable.finishDate]
} }
fun BackupTracking.toTrack(mangaId: Int): Track =
Track.create(syncId).also {
it.id = -1
it.manga_id = mangaId
it.media_id = mediaId
it.library_id = libraryId
it.title = title
it.last_chapter_read = lastChapterRead
it.total_chapters = totalChapters
it.status = status
it.score = score
it.tracking_url = trackingUrl
it.started_reading_date = startedReadingDate
it.finished_reading_date = finishedReadingDate
}
fun TrackRecordDataClass.toTrack(): Track =
Track.create(trackerId).also {
it.id = id
it.manga_id = mangaId
it.media_id = remoteId
it.library_id = libraryId
it.title = title
it.last_chapter_read = lastChapterRead.toFloat()
it.total_chapters = totalChapters
it.status = status
it.score = score.toFloat()
it.tracking_url = remoteUrl
it.started_reading_date = startDate
it.finished_reading_date = finishDate
}
fun Track.toTrackRecordDataClass(): TrackRecordDataClass =
TrackRecordDataClass(
id = id ?: -1,
mangaId = manga_id,
trackerId = sync_id,
remoteId = media_id,
libraryId = library_id,
title = title,
lastChapterRead = last_chapter_read.toDouble(),
totalChapters = total_chapters,
status = status,
score = score.toDouble(),
remoteUrl = tracking_url,
startDate = started_reading_date,
finishDate = finished_reading_date,
)