diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt index 6a900a2c..ebdd1d0e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt @@ -22,7 +22,9 @@ import suwayomi.tachidesk.graphql.types.TrackStatusType import suwayomi.tachidesk.graphql.types.TrackerType import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack +import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrackSearch import suwayomi.tachidesk.manga.model.table.TrackRecordTable +import suwayomi.tachidesk.manga.model.table.TrackSearchTable import suwayomi.tachidesk.server.JavalinSetup.future class TrackerDataLoader : KotlinDataLoader { @@ -116,7 +118,30 @@ class DisplayScoreForTrackRecordDataLoader : KotlinDataLoader { .toList() .map { it.toTrack() } .associateBy { it.id!! } - .mapValues { TrackerManager.getTracker(it.value.sync_id)?.displayScore(it.value) } + .mapValues { TrackerManager.getTracker(it.value.tracker_id)?.displayScore(it.value) } + + ids.map { trackRecords[it] } + } + } + } +} + +class DisplayScoreForTrackSearchDataLoader : KotlinDataLoader { + override val dataLoaderName = "DisplayScoreForTrackRecordDataLoader" + + override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader = + DataLoaderFactory.newDataLoader { ids -> + future { + transaction { + addLogger(Slf4jSqlDebugLogger) + val trackRecords = + TrackSearchTable + .selectAll() + .where { TrackSearchTable.id inList ids } + .toList() + .map { it.toTrackSearch() } + .associateBy { it.id!! } + .mapValues { TrackerManager.getTracker(it.value.tracker_id)?.displayScore(it.value) } ids.map { trackRecords[it] } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt index 5e2f2648..af1a65cb 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/mutations/TrackMutation.kt @@ -108,6 +108,8 @@ class TrackMutation { val mangaId: Int, val trackerId: Int, val remoteId: Long, + @GraphQLDescription("This will only work if the tracker of the track record supports private tracking") + val private: Boolean? = null, ) data class BindTrackPayload( @@ -116,13 +118,14 @@ class TrackMutation { ) fun bindTrack(input: BindTrackInput): CompletableFuture { - val (clientMutationId, mangaId, trackerId, remoteId) = input + val (clientMutationId, mangaId, trackerId, remoteId, private) = input return future { Track.bind( mangaId, trackerId, remoteId, + private ?: false, ) val trackRecord = transaction { @@ -238,8 +241,12 @@ class TrackMutation { val status: Int? = null, val lastChapterRead: Double? = null, val scoreString: String? = null, + @GraphQLDescription("This will only work if the tracker of the track record supports reading dates") val startDate: Long? = null, + @GraphQLDescription("This will only work if the tracker of the track record supports reading dates") val finishDate: Long? = null, + @GraphQLDescription("This will only work if the tracker of the track record supports private tracking") + val private: Boolean? = null, @GraphQLDeprecated("Replaced with \"unbindTrack\" mutation", replaceWith = ReplaceWith("unbindTrack")) val unbind: Boolean? = null, ) @@ -260,6 +267,7 @@ class TrackMutation { input.startDate, input.finishDate, input.unbind, + input.private, ), ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt index ad15540a..4ff568ee 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt @@ -17,6 +17,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoader +import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoader import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader @@ -83,6 +84,7 @@ class TachideskDataLoaderRegistryFactory { TrackerTokenExpiredDataLoader(), TrackRecordsForMangaIdDataLoader(), DisplayScoreForTrackRecordDataLoader(), + DisplayScoreForTrackSearchDataLoader(), TrackRecordsForTrackerIdDataLoader(), TrackRecordDataLoader(), ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt index 5dbdde09..11ae7e2d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt @@ -9,6 +9,7 @@ import suwayomi.tachidesk.graphql.server.primitives.Node import suwayomi.tachidesk.graphql.server.primitives.NodeList import suwayomi.tachidesk.graphql.server.primitives.PageInfo import suwayomi.tachidesk.manga.impl.track.Track +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker import suwayomi.tachidesk.manga.impl.track.tracker.Tracker import suwayomi.tachidesk.manga.model.table.TrackRecordTable import suwayomi.tachidesk.manga.model.table.TrackSearchTable @@ -20,7 +21,9 @@ class TrackerType( val icon: String, val isLoggedIn: Boolean, val authUrl: String?, - val supportsTrackDeletion: Boolean?, + val supportsTrackDeletion: Boolean, + val supportsReadingDates: Boolean, + val supportsPrivateTracking: Boolean, ) : Node { constructor(tracker: Tracker) : this( tracker.isLoggedIn, @@ -37,7 +40,9 @@ class TrackerType( } else { tracker.authUrl() }, - tracker.supportsTrackDeletion, + tracker is DeletableTracker, + tracker.supportsReadingDates, + tracker.supportsPrivateTracking, ) fun statuses(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture> = @@ -72,6 +77,7 @@ class TrackRecordType( val remoteUrl: String, val startDate: Long, val finishDate: Long, + val private: Boolean, ) : Node { constructor(row: ResultRow) : this( row[TrackRecordTable.id].value, @@ -87,6 +93,7 @@ class TrackRecordType( row[TrackRecordTable.remoteUrl], row[TrackRecordTable.startDate], row[TrackRecordTable.finishDate], + row[TrackRecordTable.private], ) fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture = @@ -103,7 +110,9 @@ class TrackSearchType( val id: Int, val trackerId: Int, val remoteId: Long, + val libraryId: Long?, val title: String, + val lastChapterRead: Double, val totalChapters: Int, val trackingUrl: String, val coverUrl: String, @@ -111,12 +120,19 @@ class TrackSearchType( val publishingStatus: String, val publishingType: String, val startDate: String, + val status: Int, + val score: Double, + val startedReadingDate: Long, + val finishedReadingDate: Long, + val private: Boolean, ) { constructor(row: ResultRow) : this( row[TrackSearchTable.id].value, row[TrackSearchTable.trackerId], row[TrackSearchTable.remoteId], + row[TrackSearchTable.libraryId], row[TrackSearchTable.title], + row[TrackSearchTable.lastChapterRead], row[TrackSearchTable.totalChapters], row[TrackSearchTable.trackingUrl], row[TrackSearchTable.coverUrl], @@ -124,10 +140,18 @@ class TrackSearchType( row[TrackSearchTable.publishingStatus], row[TrackSearchTable.publishingType], row[TrackSearchTable.startDate], + row[TrackSearchTable.status], + row[TrackSearchTable.score], + row[TrackSearchTable.startedReadingDate], + row[TrackSearchTable.finishedReadingDate], + row[TrackSearchTable.private], ) fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture = dataFetchingEnvironment.getValueFromDataLoader("TrackerDataLoader", trackerId) + + fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture = + dataFetchingEnvironment.getValueFromDataLoader("DisplayScoreForTrackSearchDataLoader", id) } data class TrackRecordNodeList( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt index e606bdd4..c7dcf58e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/controller/TrackController.kt @@ -114,15 +114,16 @@ object TrackController { queryParam("mangaId"), queryParam("trackerId"), queryParam("remoteId"), + queryParam("private"), documentWith = { withOperation { summary("Track Record Bind") description("Bind a Track Record to a Manga") } }, - behaviorOf = { ctx, mangaId, trackerId, remoteId -> + behaviorOf = { ctx, mangaId, trackerId, remoteId, private -> ctx.future { - future { Track.bind(mangaId, trackerId, remoteId.toLong()) } + future { Track.bind(mangaId, trackerId, remoteId.toLong(), private) } .thenApply { ctx.status(HttpStatus.OK) } } }, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt index 243fa97a..4b4dc2bf 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/ProtoBackupImport.kt @@ -473,14 +473,14 @@ object ProtoBackupImport : ProtoBackupBase() { Tracker .getTrackRecordsByMangaId(mangaId) .mapNotNull { it.record?.toTrack() } - .associateBy { it.sync_id } + .associateBy { it.tracker_id } val (existingTracks, newTracks) = tracks .mapNotNull { backupTrack -> val track = backupTrack.toTrack(mangaId) - val isUnsupportedTracker = TrackerManager.getTracker(track.sync_id) == null + val isUnsupportedTracker = TrackerManager.getTracker(track.tracker_id) == null if (isUnsupportedTracker) { return@mapNotNull null } @@ -495,7 +495,7 @@ object ProtoBackupImport : ProtoBackupBase() { } dbTrack.also { - it.media_id = track.media_id + it.remote_id = track.remote_id it.library_id = track.library_id it.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupTracking.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupTracking.kt index de6a5c30..5afcd64c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupTracking.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/backup/proto/models/BackupTracking.kt @@ -26,6 +26,7 @@ data class BackupTracking( @ProtoNumber(10) var startedReadingDate: Long = 0, // finishedReadingDate is called endReadTime in 1.x @ProtoNumber(11) var finishedReadingDate: Long = 0, + @ProtoNumber(12) var private: Boolean = false, @ProtoNumber(100) var mediaId: Long = 0, ) { fun getTrackingImpl(): TrackImpl = diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt index 7281ccda..4cbd5799 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/Track.kt @@ -16,8 +16,8 @@ import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.statements.BatchUpdateStatement import org.jetbrains.exposed.sql.transactions.transaction -import org.jetbrains.exposed.sql.update -import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService +import org.jsoup.Jsoup +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack @@ -31,6 +31,7 @@ import suwayomi.tachidesk.manga.model.table.TrackRecordTable.finishDate import suwayomi.tachidesk.manga.model.table.TrackRecordTable.lastChapterRead import suwayomi.tachidesk.manga.model.table.TrackRecordTable.libraryId import suwayomi.tachidesk.manga.model.table.TrackRecordTable.mangaId +import suwayomi.tachidesk.manga.model.table.TrackRecordTable.private import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteId import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl import suwayomi.tachidesk.manga.model.table.TrackRecordTable.score @@ -100,7 +101,7 @@ object Track { if (record != null) { val track = Track.create(it.id).also { t -> - t.score = record.score.toFloat() + t.score = record.score } record.scoreString = it.displayScore(track) } @@ -124,7 +125,9 @@ object Track { id = it[TrackSearchTable.id].value, trackerId = it[TrackSearchTable.trackerId], remoteId = it[TrackSearchTable.remoteId], + libraryId = it[TrackSearchTable.libraryId], title = it[TrackSearchTable.title], + lastChapterRead = it[TrackSearchTable.lastChapterRead], totalChapters = it[TrackSearchTable.totalChapters], trackingUrl = it[TrackSearchTable.trackingUrl], coverUrl = it[TrackSearchTable.coverUrl], @@ -132,6 +135,12 @@ object Track { publishingStatus = it[TrackSearchTable.publishingStatus], publishingType = it[TrackSearchTable.publishingType], startDate = it[TrackSearchTable.startDate], + status = it[TrackSearchTable.status], + score = it[TrackSearchTable.score], + scoreString = null, + startedReadingDate = it[TrackSearchTable.startedReadingDate], + finishedReadingDate = it[TrackSearchTable.finishedReadingDate], + private = it[TrackSearchTable.private], ) } } @@ -139,7 +148,7 @@ object Track { private fun ResultRow.toTrackFromSearch(mangaId: Int): Track = Track.create(this[TrackSearchTable.trackerId]).also { it.manga_id = mangaId - it.media_id = this[TrackSearchTable.remoteId] + it.remote_id = this[TrackSearchTable.remoteId] it.title = this[TrackSearchTable.title] it.total_chapters = this[TrackSearchTable.totalChapters] it.tracking_url = this[TrackSearchTable.trackingUrl] @@ -149,6 +158,7 @@ object Track { mangaId: Int, trackerId: Int, remoteId: Long, + private: Boolean, ) { val track = transaction { @@ -167,7 +177,8 @@ object Track { }.first() .toTrack() .apply { - manga_id = mangaId + this.manga_id = mangaId + this.private = private } } val tracker = TrackerManager.getTracker(trackerId)!! @@ -234,7 +245,7 @@ object Track { val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId]) - if (deleteRemoteTrack == true && tracker is DeletableTrackService) { + if (deleteRemoteTrack == true && tracker is DeletableTracker) { tracker.delete(recordDb.toTrack()) } @@ -278,8 +289,7 @@ object Track { } if (input.scoreString != null) { val score = tracker.indexToScore(tracker.getScoreList().indexOf(input.scoreString)) - // conversion issues between Float <-> Double so convert to string before double - recordDb[TrackRecordTable.score] = score.toString().toDouble() + recordDb[TrackRecordTable.score] = score } if (input.startDate != null) { recordDb[TrackRecordTable.startDate] = input.startDate @@ -287,6 +297,9 @@ object Track { if (input.finishDate != null) { recordDb[TrackRecordTable.finishDate] = input.finishDate } + if (input.private != null) { + recordDb[TrackRecordTable.private] = input.private + } val track = recordDb.toTrack() tracker.update(track) @@ -384,7 +397,7 @@ object Track { log.debug { "remoteLastReadChapter= $lastChapterRead" } if (chapterNumber > lastChapterRead) { - track.last_chapter_read = chapterNumber.toFloat() + track.last_chapter_read = chapterNumber tracker.update(track, true) upsertTrackRecord(track) } @@ -397,7 +410,7 @@ object Track { .selectAll() .where { (TrackRecordTable.mangaId eq track.manga_id) and - (TrackRecordTable.trackerId eq track.sync_id) + (TrackRecordTable.trackerId eq track.tracker_id) }.singleOrNull() if (existingRecord != null) { @@ -416,16 +429,17 @@ object Track { BatchUpdateStatement(TrackRecordTable).apply { tracks.forEach { addBatch(EntityID(it.id!!, TrackRecordTable)) - this[remoteId] = it.media_id + this[remoteId] = it.remote_id this[libraryId] = it.library_id this[title] = it.title - this[lastChapterRead] = it.last_chapter_read.toDouble() + this[lastChapterRead] = it.last_chapter_read this[totalChapters] = it.total_chapters this[status] = it.status - this[score] = it.score.toDouble() + this[score] = it.score this[remoteUrl] = it.tracking_url this[startDate] = it.started_reading_date this[finishDate] = it.finished_reading_date + this[private] = it.private } execute(this@transaction) } @@ -439,17 +453,18 @@ object Track { TrackRecordTable .batchInsert(tracks) { this[mangaId] = it.manga_id - this[trackerId] = it.sync_id - this[remoteId] = it.media_id + this[trackerId] = it.tracker_id + this[remoteId] = it.remote_id this[libraryId] = it.library_id this[title] = it.title - this[lastChapterRead] = it.last_chapter_read.toDouble() + this[lastChapterRead] = it.last_chapter_read this[totalChapters] = it.total_chapters this[status] = it.status - this[score] = it.score.toDouble() + this[score] = it.score this[remoteUrl] = it.tracking_url this[startDate] = it.started_reading_date this[finishDate] = it.finished_reading_date + this[private] = it.private }.map { it[TrackRecordTable.id].value } } @@ -481,5 +496,8 @@ object Track { val startDate: Long? = null, val finishDate: Long? = null, val unbind: Boolean? = null, + val private: Boolean? = null, ) + + fun String.htmlDecode(): String = Jsoup.parse(this).wholeText() } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTracker.kt similarity index 87% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTracker.kt index bf9e6cd7..f0e82023 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTrackService.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/DeletableTracker.kt @@ -5,6 +5,6 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track /** * For track services api that support deleting a manga entry for a user's list */ -interface DeletableTrackService { +interface DeletableTracker { suspend fun delete(track: Track) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt index 896f998a..92ccfd83 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt @@ -20,7 +20,7 @@ abstract class Tracker( // Application and remote support for reading dates open val supportsReadingDates: Boolean = false - abstract val supportsTrackDeletion: Boolean + open val supportsPrivateTracking: Boolean = false override fun toString() = "$name ($id) (isLoggedIn= $isLoggedIn, isAuthExpired= ${getIfAuthExpired()})" @@ -38,7 +38,7 @@ abstract class Tracker( abstract fun getScoreList(): List - open fun indexToScore(index: Int): Float = index.toFloat() + open fun indexToScore(index: Int): Double = index.toDouble() abstract fun displayScore(track: Track): String diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt index 1fc9f873..e2a2b2c8 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt @@ -27,7 +27,6 @@ object TrackerPreferences { username: String, password: String, ) { - logger.debug { "setTrackCredentials: id=${sync.id} username=$username" } preferenceStore .edit() .putString(trackUsername(sync.id), username) @@ -42,7 +41,6 @@ object TrackerPreferences { sync: Tracker, token: String?, ) { - logger.debug { "setTrackToken: id=${sync.id} token=$token" } if (token == null) { preferenceStore .edit() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt index c219d17a..97d039dd 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/Anilist.kt @@ -2,10 +2,10 @@ package suwayomi.tachidesk.manga.impl.track.tracker.anilist import android.annotation.StringRes import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker import suwayomi.tachidesk.manga.impl.track.tracker.Tracker +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALOAuth import suwayomi.tachidesk.manga.impl.track.tracker.extractToken import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch @@ -15,7 +15,7 @@ import java.io.IOException class Anilist( id: Int, ) : Tracker(id, "AniList"), - DeletableTrackService { + DeletableTracker { companion object { const val READING = 1 const val COMPLETED = 2 @@ -31,8 +31,6 @@ class Anilist( const val POINT_3 = "POINT_3" } - override val supportsTrackDeletion: Boolean = true - private val json: Json by injectLazy() private val interceptor by lazy { AnilistInterceptor(this) } @@ -41,6 +39,8 @@ class Anilist( override val supportsReadingDates: Boolean = true + override val supportsPrivateTracking: Boolean = true + private val logger = KotlinLogging.logger {} override fun getLogo(): String = "/static/tracker/anilist.png" @@ -80,26 +80,26 @@ class Anilist( else -> throw Exception("Unknown score type") } - override fun indexToScore(index: Int): Float = + override fun indexToScore(index: Int): Double = when (trackPreferences.getScoreType(this)) { // 10 point - POINT_10 -> index * 10f + POINT_10 -> index * 10.0 // 100 point - POINT_100 -> index.toFloat() + POINT_100 -> index.toDouble() // 5 stars POINT_5 -> when (index) { - 0 -> 0f - else -> index * 20f - 10f + 0 -> 0.0 + else -> index * 20.0 - 10.0 } // Smiley POINT_3 -> when (index) { - 0 -> 0f - else -> index * 25f + 10f + 0 -> 0.0 + else -> index * 25.0 + 10.0 } // 10 point decimal - POINT_10_DECIMAL -> index.toFloat() + POINT_10_DECIMAL -> index.toDouble() else -> throw Exception("Unknown score type") } @@ -108,17 +108,17 @@ class Anilist( return when (val type = trackPreferences.getScoreType(this)) { POINT_5 -> when (score) { - 0f -> "0 ★" + 0.0 -> "0 ★" else -> "${((score + 10) / 20).toInt()} ★" } POINT_3 -> when { - score == 0f -> "0" + score == 0.0 -> "0" score <= 35 -> "😦" score <= 60 -> "😐" else -> "😊" } - else -> track.toAnilistScore(type) + else -> track.toApiScore(type) } } @@ -143,7 +143,7 @@ class Anilist( track.finished_reading_date = System.currentTimeMillis() } else if (track.status != REREADING) { track.status = READING - if (track.last_chapter_read == 1F) { + if (track.last_chapter_read == 1.0) { track.started_reading_date = System.currentTimeMillis() } } @@ -168,19 +168,19 @@ class Anilist( ): Track { val remoteTrack = api.findLibManga(track, getUsername().toInt()) return if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) + track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false) track.library_id = remoteTrack.library_id if (track.status != COMPLETED) { val isRereading = track.status == REREADING - track.status = if (isRereading.not() && hasReadChapters) READING else track.status + track.status = if (!isRereading && hasReadChapters) READING else track.status } update(track) } else { // Set default fields if it's not found in the list track.status = if (hasReadChapters) READING else PLAN_TO_READ - track.score = 0F + track.score = 0.0 add(track) } } @@ -209,12 +209,11 @@ class Anilist( private suspend fun login(token: String) { try { - logger.debug { "login $token" } val oauth = api.createOAuth(token) interceptor.setAuth(oauth) val (username, scoreType) = api.getCurrentUser() trackPreferences.setScoreType(this, scoreType) - saveCredentials(username.toString(), oauth.access_token) + saveCredentials(username.toString(), oauth.accessToken) } catch (e: Throwable) { logger.error(e) { "oauth err" } logout() @@ -228,13 +227,13 @@ class Anilist( interceptor.setAuth(null) } - fun saveOAuth(oAuth: OAuth?) { + fun saveOAuth(oAuth: ALOAuth?) { trackPreferences.setTrackToken(this, json.encodeToString(oAuth)) } - fun loadOAuth(): OAuth? = + fun loadOAuth(): ALOAuth? = try { - json.decodeFromString(trackPreferences.getTrackToken(this)!!) + json.decodeFromString(trackPreferences.getTrackToken(this)!!) } catch (e: Exception) { logger.error(e) { "loadOAuth err" } null diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistApi.kt index c26a45b5..1a6b3de7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistApi.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistApi.kt @@ -12,22 +12,21 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.int -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALAddMangaResult +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALCurrentUserResult +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALOAuth +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALSearchResult +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALUserListMangaQueryResult import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import uy.kohesive.injekt.injectLazy -import java.util.Calendar -import kotlin.time.Duration.Companion.days +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime import kotlin.time.Duration.Companion.minutes class AnilistApi( @@ -47,11 +46,11 @@ class AnilistApi( withIOContext { val query = """ - |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { - |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) { - | id - | status - |} + |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean) { + |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private) { + | id + | status + |} |} | """.trimMargin() @@ -59,9 +58,10 @@ class AnilistApi( buildJsonObject { put("query", query) putJsonObject("variables") { - put("mangaId", track.media_id) + put("mangaId", track.remote_id) put("progress", track.last_chapter_read.toInt()) - put("status", track.toAnilistStatus()) + put("status", track.toApiStatus()) + put("private", track.private) } } with(json) { @@ -72,13 +72,9 @@ class AnilistApi( body = payload.toString().toRequestBody(jsonMime), ), ).awaitSuccess() - .parseAs() + .parseAs() .let { - track.library_id = - it["data"]!! - .jsonObject["SaveMediaListEntry"]!! - .jsonObject["id"]!! - .jsonPrimitive.long + track.library_id = it.data.entry.id track } } @@ -89,11 +85,11 @@ class AnilistApi( val query = """ |mutation UpdateManga( - |${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, + |${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean, |${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput |) { |SaveMediaListEntry( - |id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, + |id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private, |scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt |) { |id @@ -109,10 +105,11 @@ class AnilistApi( putJsonObject("variables") { put("listId", track.library_id) put("progress", track.last_chapter_read.toInt()) - put("status", track.toAnilistStatus()) + put("status", track.toApiStatus()) put("score", track.score.toInt()) put("startedAt", createDate(track.started_reading_date)) put("completedAt", createDate(track.finished_reading_date)) + put("private", track.private) } } authClient @@ -121,14 +118,14 @@ class AnilistApi( track } - suspend fun deleteLibManga(track: Track) = + suspend fun deleteLibManga(track: Track) { withIOContext { val query = """ |mutation DeleteManga(${'$'}listId: Int) { - |DeleteMediaListEntry(id: ${'$'}listId) { + |DeleteMediaListEntry(id: ${'$'}listId) { |deleted - |} + |} |} | """.trimMargin() @@ -143,6 +140,7 @@ class AnilistApi( .newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime))) .awaitSuccess() } + } suspend fun search(search: String): List = withIOContext { @@ -152,6 +150,19 @@ class AnilistApi( |Page (perPage: 50) { |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { |id + |staff { + |edges { + |role + |id + |node { + |name { + |full + |userPreferred + |native + |} + |} + |} + |} |title { |userPreferred |} @@ -167,6 +178,7 @@ class AnilistApi( |month |day |} + |averageScore |} |} |} @@ -187,14 +199,9 @@ class AnilistApi( body = payload.toString().toRequestBody(jsonMime), ), ).awaitSuccess() - .parseAs() - .let { response -> - val data = response["data"]!!.jsonObject - val page = data["Page"]!!.jsonObject - val media = page["media"]!!.jsonArray - val entries = media.map { jsonToALManga(it.jsonObject) } - entries.map { it.toTrack() } - } + .parseAs() + .data.page.media + .map { it.toALManga().toTrack() } } } @@ -212,6 +219,7 @@ class AnilistApi( |status |scoreRaw: score(format: POINT_100) |progress + |private |startedAt { |year |month @@ -239,6 +247,19 @@ class AnilistApi( |month |day |} + |staff { + |edges { + |role + |id + |node { + |name { + |full + |userPreferred + |native + |} + |} + |} + |} |} |} |} @@ -250,7 +271,7 @@ class AnilistApi( put("query", query) putJsonObject("variables") { put("id", userid) - put("manga_id", track.media_id) + put("manga_id", track.remote_id) } } with(json) { @@ -261,24 +282,20 @@ class AnilistApi( body = payload.toString().toRequestBody(jsonMime), ), ).awaitSuccess() - .parseAs() - .let { response -> - val data = response["data"]!!.jsonObject - val page = data["Page"]!!.jsonObject - val media = page["mediaList"]!!.jsonArray - val entries = media.map { jsonToALUserManga(it.jsonObject) } - entries.firstOrNull()?.toTrack() - } + .parseAs() + .data.page.mediaList + .map { it.toALUserManga() } + .firstOrNull() + ?.toTrack() } } suspend fun getLibManga( track: Track, - userid: Int, - ): Track = findLibManga(track, userid) ?: throw Exception("Could not find manga") + userId: Int, + ): Track = findLibManga(track, userId) ?: throw Exception("Could not find manga") - fun createOAuth(token: String): OAuth = - OAuth(token, "Bearer", System.currentTimeMillis() + 365.days.inWholeMilliseconds, 365.days.inWholeMilliseconds) + fun createOAuth(token: String): ALOAuth = ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000) suspend fun getCurrentUser(): Pair = withIOContext { @@ -306,57 +323,14 @@ class AnilistApi( body = payload.toString().toRequestBody(jsonMime), ), ).awaitSuccess() - .parseAs() + .parseAs() .let { - val data = it["data"]!!.jsonObject - val viewer = data["Viewer"]!!.jsonObject - Pair( - viewer["id"]!!.jsonPrimitive.int, - viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content, - ) + val viewer = it.data.viewer + Pair(viewer.id, viewer.mediaListOptions.scoreFormat) } } } - private fun jsonToALManga(struct: JsonObject): ALManga = - ALManga( - struct["id"]!!.jsonPrimitive.long, - struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content, - struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content, - struct["description"]!!.jsonPrimitive.contentOrNull, - struct["format"]!!.jsonPrimitive.content.replace("_", "-"), - struct["status"]!!.jsonPrimitive.contentOrNull ?: "", - parseDate(struct, "startDate"), - struct["chapters"]!!.jsonPrimitive.intOrNull ?: 0, - ) - - private fun jsonToALUserManga(struct: JsonObject): ALUserManga = - ALUserManga( - struct["id"]!!.jsonPrimitive.long, - struct["status"]!!.jsonPrimitive.content, - struct["scoreRaw"]!!.jsonPrimitive.int, - struct["progress"]!!.jsonPrimitive.int, - parseDate(struct, "startedAt"), - parseDate(struct, "completedAt"), - jsonToALManga(struct["media"]!!.jsonObject), - ) - - private fun parseDate( - struct: JsonObject, - dateKey: String, - ): Long = - try { - val date = Calendar.getInstance() - date.set( - struct[dateKey]!!.jsonObject["year"]!!.jsonPrimitive.int, - struct[dateKey]!!.jsonObject["month"]!!.jsonPrimitive.int - 1, - struct[dateKey]!!.jsonObject["day"]!!.jsonPrimitive.int, - ) - date.timeInMillis - } catch (_: Exception) { - 0L - } - private fun createDate(dateValue: Long): JsonObject { if (dateValue == 0L) { return buildJsonObject { @@ -366,12 +340,11 @@ class AnilistApi( } } - val calendar = Calendar.getInstance() - calendar.timeInMillis = dateValue + val dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(dateValue), ZoneId.systemDefault()) return buildJsonObject { - put("year", calendar.get(Calendar.YEAR)) - put("month", calendar.get(Calendar.MONTH) + 1) - put("day", calendar.get(Calendar.DAY_OF_MONTH)) + put("year", dateTime.year) + put("month", dateTime.monthValue) + put("day", dateTime.dayOfMonth) } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt index 3964cb9f..7798e5ac 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt @@ -3,10 +3,13 @@ package suwayomi.tachidesk.manga.impl.track.tracker.anilist import okhttp3.Interceptor import okhttp3.Response import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALOAuth +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.isExpired +import suwayomi.tachidesk.server.generated.BuildConfig import java.io.IOException class AnilistInterceptor( - private val anilist: Anilist, + val anilist: Anilist, ) : Interceptor { /** * OAuth object used for authenticated requests. @@ -14,7 +17,7 @@ class AnilistInterceptor( * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute * before its original expiration date. */ - private var oauth: OAuth? = null + private var oauth: ALOAuth? = null set(value) { field = value?.copy(expires = value.expires * 1000 - 60 * 1000) } @@ -44,7 +47,8 @@ class AnilistInterceptor( val authRequest = originalRequest .newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .addHeader("Authorization", "Bearer ${oauth!!.accessToken}") + .header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})") .build() return chain.proceed(authRequest) @@ -54,7 +58,7 @@ class AnilistInterceptor( * Called when the user authenticates with Anilist for the first time. Sets the refresh token * and the oauth object. */ - fun setAuth(oauth: OAuth?) { + fun setAuth(oauth: ALOAuth?) { this.oauth = oauth anilist.saveOAuth(oauth) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistModels.kt deleted file mode 100644 index 5c5366af..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistModels.kt +++ /dev/null @@ -1,124 +0,0 @@ -package suwayomi.tachidesk.manga.impl.track.tracker.anilist - -import kotlinx.serialization.Serializable -import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager -import suwayomi.tachidesk.manga.impl.track.tracker.model.Track -import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch -import java.text.SimpleDateFormat -import java.util.Locale - -data class ALManga( - val media_id: Long, - val title_user_pref: String, - val image_url_lge: String, - val description: String?, - val format: String, - val publishing_status: String, - val start_date_fuzzy: Long, - val total_chapters: Int, -) { - fun toTrack() = - TrackSearch.create(TrackerManager.ANILIST).apply { - media_id = this@ALManga.media_id - title = title_user_pref - total_chapters = this@ALManga.total_chapters - cover_url = image_url_lge - summary = description ?: "" - tracking_url = AnilistApi.mangaUrl(media_id) - publishing_status = this@ALManga.publishing_status - publishing_type = format - if (start_date_fuzzy != 0L) { - start_date = - try { - val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) - outputDf.format(start_date_fuzzy) - } catch (e: Exception) { - "" - } - } - } -} - -data class ALUserManga( - val library_id: Long, - val list_status: String, - val score_raw: Int, - val chapters_read: Int, - val start_date_fuzzy: Long, - val completed_date_fuzzy: Long, - val manga: ALManga, -) { - fun toTrack() = - Track.create(TrackerManager.ANILIST).apply { - media_id = manga.media_id - title = manga.title_user_pref - status = toTrackStatus() - score = score_raw.toFloat() - started_reading_date = start_date_fuzzy - finished_reading_date = completed_date_fuzzy - last_chapter_read = chapters_read.toFloat() - library_id = this@ALUserManga.library_id - total_chapters = manga.total_chapters - } - - fun toTrackStatus() = - when (list_status) { - "CURRENT" -> Anilist.READING - "COMPLETED" -> Anilist.COMPLETED - "PAUSED" -> Anilist.ON_HOLD - "DROPPED" -> Anilist.DROPPED - "PLANNING" -> Anilist.PLAN_TO_READ - "REPEATING" -> Anilist.REREADING - else -> throw NotImplementedError("Unknown status: $list_status") - } -} - -@Serializable -data class OAuth( - val access_token: String, - val token_type: String, - val expires: Long, - val expires_in: Long, -) - -fun OAuth.isExpired() = System.currentTimeMillis() > expires - -fun Track.toAnilistStatus() = - when (status) { - Anilist.READING -> "CURRENT" - Anilist.COMPLETED -> "COMPLETED" - Anilist.ON_HOLD -> "PAUSED" - Anilist.DROPPED -> "DROPPED" - Anilist.PLAN_TO_READ -> "PLANNING" - Anilist.REREADING -> "REPEATING" - else -> throw NotImplementedError("Unknown status: $status") - } - -fun Track.toAnilistScore(scoreType: String?): String = - when (scoreType) { -// 10 point - "POINT_10" -> (score.toInt() / 10).toString() -// 100 point - "POINT_100" -> score.toInt().toString() -// 5 stars - "POINT_5" -> - when { - score == 0f -> "0" - score < 30 -> "1" - score < 50 -> "2" - score < 70 -> "3" - score < 90 -> "4" - else -> "5" - } -// Smiley - "POINT_3" -> - when { - score == 0f -> "0" - score <= 35 -> ":(" - score <= 60 -> ":|" - else -> ":)" - } -// 10 point decimal - "POINT_10_DECIMAL" -> (score / 10).toString() - else -> throw NotImplementedError("Unknown score type") - } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistUtils.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistUtils.kt new file mode 100644 index 00000000..8433dcb4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistUtils.kt @@ -0,0 +1,43 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist + +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track + +fun Track.toApiStatus() = + when (status) { + Anilist.READING -> "CURRENT" + Anilist.COMPLETED -> "COMPLETED" + Anilist.ON_HOLD -> "PAUSED" + Anilist.DROPPED -> "DROPPED" + Anilist.PLAN_TO_READ -> "PLANNING" + Anilist.REREADING -> "REPEATING" + else -> throw NotImplementedError("Unknown status: $status") + } + +fun Track.toApiScore(scoreType: String?): String = + when (scoreType) { + // 10 point + "POINT_10" -> (score.toInt() / 10).toString() + // 100 point + "POINT_100" -> score.toInt().toString() + // 5 stars + "POINT_5" -> + when { + score == 0.0 -> "0" + score < 30 -> "1" + score < 50 -> "2" + score < 70 -> "3" + score < 90 -> "4" + else -> "5" + } + // Smiley + "POINT_3" -> + when { + score == 0.0 -> "0" + score <= 35 -> ":(" + score <= 60 -> ":|" + else -> ":)" + } + // 10 point decimal + "POINT_10_DECIMAL" -> (score / 10).toString() + else -> throw NotImplementedError("Unknown score type") + } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALAddManga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALAddManga.kt new file mode 100644 index 00000000..4890bb28 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALAddManga.kt @@ -0,0 +1,20 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALAddMangaResult( + val data: ALAddMangaData, +) + +@Serializable +data class ALAddMangaData( + @SerialName("SaveMediaListEntry") + val entry: ALAddMangaEntry, +) + +@Serializable +data class ALAddMangaEntry( + val id: Long, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALFuzzyDate.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALFuzzyDate.kt new file mode 100644 index 00000000..140346b7 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALFuzzyDate.kt @@ -0,0 +1,23 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto + +import kotlinx.serialization.Serializable +import java.time.LocalDate +import java.time.ZoneId + +@Serializable +data class ALFuzzyDate( + val year: Int?, + val month: Int?, + val day: Int?, +) { + fun toEpochMilli(): Long = + try { + LocalDate + .of(year!!, month!!, day!!) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } catch (_: Exception) { + 0L + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALManga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALManga.kt new file mode 100644 index 00000000..822f0aa4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALManga.kt @@ -0,0 +1,86 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto + +import suwayomi.tachidesk.manga.impl.track.Track.htmlDecode +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist +import suwayomi.tachidesk.manga.impl.track.tracker.anilist.AnilistApi +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import java.text.SimpleDateFormat +import java.util.Locale + +data class ALManga( + val remoteId: Long, + val title: String, + val imageUrl: String, + val description: String?, + val format: String, + val publishingStatus: String, + val startDateFuzzy: Long, + val totalChapters: Int, + val averageScore: Int, + val staff: ALStaff, +) { + fun toTrack() = + TrackSearch.create(TrackerManager.ANILIST).apply { + remote_id = remoteId + title = this@ALManga.title + total_chapters = totalChapters + cover_url = imageUrl + summary = description?.htmlDecode() ?: "" + score = averageScore.toDouble() + tracking_url = AnilistApi.mangaUrl(remote_id) + publishing_status = publishingStatus + publishing_type = format + if (startDateFuzzy != 0L) { + start_date = + try { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(startDateFuzzy) + } catch (e: IllegalArgumentException) { + "" + } + } + staff.edges.forEach { + val name = it.node.name() ?: return@forEach + if ("Story" in it.role) authors += name + if ("Art" in it.role) artists += name + } + } +} + +data class ALUserManga( + val libraryId: Long, + val listStatus: String, + val scoreRaw: Int, + val chaptersRead: Int, + val startDateFuzzy: Long, + val completedDateFuzzy: Long, + val manga: ALManga, + val private: Boolean, +) { + fun toTrack() = + Track.create(TrackerManager.ANILIST).apply { + remote_id = manga.remoteId + title = manga.title + status = toTrackStatus() + score = scoreRaw.toDouble() + started_reading_date = startDateFuzzy + finished_reading_date = completedDateFuzzy + last_chapter_read = chaptersRead.toDouble() + library_id = libraryId + total_chapters = manga.totalChapters + private = this@ALUserManga.private + } + + private fun toTrackStatus() = + when (listStatus) { + "CURRENT" -> Anilist.READING + "COMPLETED" -> Anilist.COMPLETED + "PAUSED" -> Anilist.ON_HOLD + "DROPPED" -> Anilist.DROPPED + "PLANNING" -> Anilist.PLAN_TO_READ + "REPEATING" -> Anilist.REREADING + else -> throw NotImplementedError("Unknown status: $listStatus") + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALOAuth.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALOAuth.kt new file mode 100644 index 00000000..16ce2ad1 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALOAuth.kt @@ -0,0 +1,17 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALOAuth( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + val expires: Long, + @SerialName("expires_in") + val expiresIn: Long, +) + +fun ALOAuth.isExpired() = System.currentTimeMillis() > expires diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALSearch.kt new file mode 100644 index 00000000..bef3aedd --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALSearch.kt @@ -0,0 +1,20 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALSearchResult( + val data: ALSearchPage, +) + +@Serializable +data class ALSearchPage( + @SerialName("Page") + val page: ALSearchMedia, +) + +@Serializable +data class ALSearchMedia( + val media: List, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALSearchItem.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALSearchItem.kt new file mode 100644 index 00000000..30b32cee --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALSearchItem.kt @@ -0,0 +1,67 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ALSearchItem( + val id: Long, + val title: ALItemTitle, + val coverImage: ItemCover, + val description: String?, + val format: String, + val status: String?, + val startDate: ALFuzzyDate, + val chapters: Int?, + val averageScore: Int?, + val staff: ALStaff, +) { + fun toALManga(): ALManga = + ALManga( + remoteId = id, + title = title.userPreferred, + imageUrl = coverImage.large, + description = description, + format = format.replace("_", "-"), + publishingStatus = status ?: "", + startDateFuzzy = startDate.toEpochMilli(), + totalChapters = chapters ?: 0, + averageScore = averageScore ?: -1, + staff = staff, + ) +} + +@Serializable +data class ALItemTitle( + val userPreferred: String, +) + +@Serializable +data class ItemCover( + val large: String, +) + +@Serializable +data class ALStaff( + val edges: List, +) + +@Serializable +data class ALEdge( + val role: String, + val id: Int, + val node: ALStaffNode, +) + +@Serializable +data class ALStaffNode( + val name: ALStaffName, +) + +@Serializable +data class ALStaffName( + val userPreferred: String?, + val native: String?, + val full: String?, +) { + operator fun invoke(): String? = userPreferred ?: full ?: native +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALUser.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALUser.kt new file mode 100644 index 00000000..a862013a --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALUser.kt @@ -0,0 +1,26 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALCurrentUserResult( + val data: ALUserViewer, +) + +@Serializable +data class ALUserViewer( + @SerialName("Viewer") + val viewer: ALUserViewerData, +) + +@Serializable +data class ALUserViewerData( + val id: Int, + val mediaListOptions: ALUserListOptions, +) + +@Serializable +data class ALUserListOptions( + val scoreFormat: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALUserList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALUserList.kt new file mode 100644 index 00000000..b6ed1097 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/dto/ALUserList.kt @@ -0,0 +1,44 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ALUserListMangaQueryResult( + val data: ALUserListMangaPage, +) + +@Serializable +data class ALUserListMangaPage( + @SerialName("Page") + val page: ALUserListMediaList, +) + +@Serializable +data class ALUserListMediaList( + val mediaList: List, +) + +@Serializable +data class ALUserListItem( + val id: Long, + val status: String, + val scoreRaw: Int, + val progress: Int, + val startedAt: ALFuzzyDate, + val completedAt: ALFuzzyDate, + val media: ALSearchItem, + val private: Boolean, +) { + fun toALUserManga(): ALUserManga = + ALUserManga( + libraryId = this@ALUserListItem.id, + listStatus = status, + scoreRaw = scoreRaw, + chaptersRead = progress, + startDateFuzzy = startedAt.toEpochMilli(), + completedDateFuzzy = completedAt.toEpochMilli(), + manga = media.toALManga(), + private = private, + ) +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/Bangumi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/Bangumi.kt index c68f6d28..ed9994a1 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/Bangumi.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/Bangumi.kt @@ -3,6 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.bangumi import android.annotation.StringRes import kotlinx.serialization.json.Json import suwayomi.tachidesk.manga.impl.track.tracker.Tracker +import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMOAuth import suwayomi.tachidesk.manga.impl.track.tracker.extractToken import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch @@ -24,14 +25,14 @@ class Bangumi( .map(Int::toString) } - override val supportsTrackDeletion: Boolean = false - private val json: Json by injectLazy() private val interceptor by lazy { BangumiInterceptor(this) } private val api by lazy { BangumiApi(id, client, interceptor) } + override val supportsPrivateTracking: Boolean = true + override fun getScoreList(): List = SCORE_LIST override fun displayScore(track: Track): String = track.score.toInt().toString() @@ -61,7 +62,7 @@ class Bangumi( ): Track { val statusTrack = api.statusLibManga(track, getUsername()) return if (statusTrack != null) { - track.copyPersonalFrom(statusTrack) + track.copyPersonalFrom(statusTrack, copyRemotePrivate = false) track.library_id = statusTrack.library_id track.score = statusTrack.score track.last_chapter_read = statusTrack.last_chapter_read @@ -74,7 +75,7 @@ class Bangumi( } else { // Set default fields if it's not found in the list track.status = if (hasReadChapters) READING else PLAN_TO_READ - track.score = 0.0F + track.score = 0.0 add(track) } } @@ -151,13 +152,3 @@ class Bangumi( interceptor.newAuth(null) } } - -fun Track.toApiStatus() = - when (status) { - Bangumi.PLAN_TO_READ -> 1 - Bangumi.COMPLETED -> 2 - Bangumi.READING -> 3 - Bangumi.ON_HOLD -> 4 - Bangumi.DROPPED -> 5 - else -> throw NotImplementedError("Unknown status: $status") - } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiApi.kt index 94a30b26..e8631eaa 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiApi.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiApi.kt @@ -20,6 +20,10 @@ import okhttp3.Headers.Companion.headersOf import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody +import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMCollectionResponse +import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMOAuth +import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMSearchResult +import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMUser import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import uy.kohesive.injekt.injectLazy @@ -35,27 +39,33 @@ class BangumiApi( suspend fun addLibManga(track: Track): Track = withIOContext { - val url = "$API_URL/v0/users/-/collections/${track.media_id}" + val url = "$API_URL/v0/users/-/collections/${track.remote_id}" val body = buildJsonObject { put("type", track.toApiStatus()) put("rate", track.score.toInt().coerceIn(0, 10)) put("ep_status", track.last_chapter_read.toInt()) - }.toString().toRequestBody() + put("private", track.private) + }.toString() + .toRequestBody() // Returns with 202 Accepted on success with no body - authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))).awaitSuccess() + authClient + .newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) + .awaitSuccess() track } suspend fun updateLibManga(track: Track): Track = withIOContext { - val url = "$API_URL/v0/users/-/collections/${track.media_id}" + val url = "$API_URL/v0/users/-/collections/${track.remote_id}" val body = buildJsonObject { put("type", track.toApiStatus()) put("rate", track.score.toInt().coerceIn(0, 10)) put("ep_status", track.last_chapter_read.toInt()) - }.toString().toRequestBody() + put("private", track.private) + }.toString() + .toRequestBody() val request = Request @@ -65,7 +75,9 @@ class BangumiApi( .headers(headersOf("Content-Type", APP_JSON)) .build() // Returns with 204 No Content - authClient.newCall(request).awaitSuccess() + authClient + .newCall(request) + .awaitSuccess() track } @@ -86,7 +98,8 @@ class BangumiApi( add(1) // "Book" (书籍) type } } - }.toString().toRequestBody() + }.toString() + .toRequestBody() with(json) { authClient .newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) @@ -104,7 +117,7 @@ class BangumiApi( username: String, ): Track? = withIOContext { - val url = "$API_URL/v0/users/$username/collections/${track.media_id}" + val url = "$API_URL/v0/users/$username/collections/${track.remote_id}" with(json) { try { authClient @@ -113,8 +126,8 @@ class BangumiApi( .parseAs() .let { track.status = it.getStatus() - track.last_chapter_read = it.epStatus?.toFloat() ?: 0.0F - track.score = it.rate?.toFloat() ?: 0.0F + track.last_chapter_read = it.epStatus?.toDouble() ?: 0.0 + track.score = it.rate?.toDouble() ?: 0.0 track.total_chapters = it.subject?.eps ?: 0 track } @@ -140,7 +153,10 @@ class BangumiApi( .add("redirect_uri", REDIRECT_URL) .build() with(json) { - client.newCall(POST(OAUTH_URL, body = body)).awaitSuccess().parseAs() + client + .newCall(POST(OAUTH_URL, body = body)) + .awaitSuccess() + .parseAs() } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiInterceptor.kt index cb80b252..871d2566 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiInterceptor.kt @@ -3,6 +3,8 @@ package suwayomi.tachidesk.manga.impl.track.tracker.bangumi import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Response +import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMOAuth +import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.isExpired import suwayomi.tachidesk.server.generated.BuildConfig import uy.kohesive.injekt.injectLazy @@ -35,7 +37,7 @@ class BangumiInterceptor( .newBuilder() .header( "User-Agent", - "Suwayomi/Suwayomi-Server/v${BuildConfig.VERSION} (${BuildConfig.GITHUB})", + "Suwayomi/Suwayomi-Server/${BuildConfig.VERSION} (${BuildConfig.GITHUB})", ).apply { addHeader("Authorization", "Bearer ${currAuth.accessToken}") }.build() diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiModels.kt deleted file mode 100644 index 6c10d7fc..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiModels.kt +++ /dev/null @@ -1,115 +0,0 @@ -package suwayomi.tachidesk.manga.impl.track.tracker.bangumi - -import kotlinx.serialization.EncodeDefault -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager -import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch - -@Serializable -// Incomplete DTO with only our needed attributes -data class BGMUser( - val username: String, -) - -@Serializable -data class BGMSearchResult( - val total: Int, - val limit: Int, - val offset: Int, - val data: List = emptyList(), -) - -@Serializable -// Incomplete DTO with only our needed attributes -data class BGMSubject( - val id: Long, - @SerialName("name_cn") - val nameCn: String, - val name: String, - val summary: String?, - val date: String?, // YYYY-MM-DD - val images: BGMSubjectImages?, - val volumes: Long = 0, - val eps: Long = 0, - val rating: BGMSubjectRating?, - val platform: String?, -) { - fun toTrackSearch(trackId: Int): TrackSearch = - TrackSearch.create(TrackerManager.BANGUMI).apply { - media_id = this@BGMSubject.id - title = nameCn.ifBlank { name } - cover_url = images?.common.orEmpty() - summary = - if (nameCn.isNotBlank()) { - "作品原名:$name" + this@BGMSubject.summary?.let { "\n${it.trim()}" }.orEmpty() - } else { - this@BGMSubject.summary?.trim().orEmpty() - } - tracking_url = "https://bangumi.tv/subject/${this@BGMSubject.id}" - total_chapters = eps.toInt() - start_date = date ?: "" - } -} - -@Serializable -// Incomplete DTO with only our needed attributes -data class BGMSubjectImages( - val common: String?, -) - -@Serializable -// Incomplete DTO with only our needed attributes -data class BGMSubjectRating( - val score: Double?, -) - -@Serializable -data class BGMOAuth( - @SerialName("access_token") - val accessToken: String, - @SerialName("token_type") - val tokenType: String, - @SerialName("created_at") - @EncodeDefault - val createdAt: Long = System.currentTimeMillis() / 1000, - @SerialName("expires_in") - val expiresIn: Long, - @SerialName("refresh_token") - val refreshToken: String?, - @SerialName("user_id") - val userId: Long?, -) - -// Access token refresh before expired -fun BGMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600) - -@Serializable -// Incomplete DTO with only our needed attributes -data class BGMCollectionResponse( - val rate: Int?, - val type: Int?, - @SerialName("ep_status") - val epStatus: Int? = 0, - @SerialName("vol_status") - val volStatus: Int? = 0, - val private: Boolean = false, - val subject: BGMSlimSubject? = null, -) { - fun getStatus(): Int = - when (type) { - 1 -> Bangumi.PLAN_TO_READ - 2 -> Bangumi.COMPLETED - 3 -> Bangumi.READING - 4 -> Bangumi.ON_HOLD - 5 -> Bangumi.DROPPED - else -> throw NotImplementedError("Unknown status: $type") - } -} - -@Serializable -// Incomplete DTO with only our needed attributes -data class BGMSlimSubject( - val volumes: Int?, - val eps: Int?, -) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiUtils.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiUtils.kt new file mode 100644 index 00000000..542e68fc --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/BangumiUtils.kt @@ -0,0 +1,13 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.bangumi + +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track + +fun Track.toApiStatus() = + when (status) { + Bangumi.PLAN_TO_READ -> 1 + Bangumi.COMPLETED -> 2 + Bangumi.READING -> 3 + Bangumi.ON_HOLD -> 4 + Bangumi.DROPPED -> 5 + else -> throw NotImplementedError("Unknown status: $status") + } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMCollectionResponse.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMCollectionResponse.kt new file mode 100644 index 00000000..0d0a63ae --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMCollectionResponse.kt @@ -0,0 +1,35 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.Bangumi + +@Serializable +// Incomplete DTO with only our needed attributes +data class BGMCollectionResponse( + val rate: Int?, + val type: Int?, + @SerialName("ep_status") + val epStatus: Int? = 0, + @SerialName("vol_status") + val volStatus: Int? = 0, + val private: Boolean = false, + val subject: BGMSlimSubject? = null, +) { + fun getStatus(): Int = + when (type) { + 1 -> Bangumi.PLAN_TO_READ + 2 -> Bangumi.COMPLETED + 3 -> Bangumi.READING + 4 -> Bangumi.ON_HOLD + 5 -> Bangumi.DROPPED + else -> throw NotImplementedError("Unknown status: $type") + } +} + +@Serializable +// Incomplete DTO with only our needed attributes +data class BGMSlimSubject( + val volumes: Int?, + val eps: Int?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMOAuth.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMOAuth.kt new file mode 100644 index 00000000..187d4bab --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMOAuth.kt @@ -0,0 +1,25 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto + +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BGMOAuth( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("created_at") + @EncodeDefault + val createdAt: Long = System.currentTimeMillis() / 1000, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("refresh_token") + val refreshToken: String?, + @SerialName("user_id") + val userId: Long?, +) + +// Access token refresh before expired +fun BGMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMSearch.kt new file mode 100644 index 00000000..d26a3f42 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMSearch.kt @@ -0,0 +1,58 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch + +@Serializable +data class BGMSearchResult( + val total: Int, + val limit: Int, + val offset: Int, + val data: List = emptyList(), +) + +@Serializable +// Incomplete DTO with only our needed attributes +data class BGMSubject( + val id: Long, + @SerialName("name_cn") + val nameCn: String, + val name: String, + val summary: String?, + val date: String?, // YYYY-MM-DD + val images: BGMSubjectImages?, + val volumes: Long = 0, + val eps: Int = 0, + val rating: BGMSubjectRating?, + val platform: String?, +) { + fun toTrackSearch(trackId: Int): TrackSearch = + TrackSearch.create(trackId).apply { + remote_id = this@BGMSubject.id + title = nameCn.ifBlank { name } + cover_url = images?.common.orEmpty() + summary = + if (nameCn.isNotBlank()) { + "作品原名:$name" + this@BGMSubject.summary?.let { "\n${it.trim()}" }.orEmpty() + } else { + this@BGMSubject.summary?.trim().orEmpty() + } + score = rating?.score ?: -1.0 + tracking_url = "https://bangumi.tv/subject/${this@BGMSubject.id}" + total_chapters = eps + start_date = date ?: "" + } +} + +@Serializable +// Incomplete DTO with only our needed attributes +data class BGMSubjectImages( + val common: String?, +) + +@Serializable +// Incomplete DTO with only our needed attributes +data class BGMSubjectRating( + val score: Double?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMUser.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMUser.kt new file mode 100644 index 00000000..b48608bf --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/bangumi/dto/BGMUser.kt @@ -0,0 +1,9 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto + +import kotlinx.serialization.Serializable + +@Serializable +// Incomplete DTO with only our needed attributes +data class BGMUser( + val username: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/Kitsu.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/Kitsu.kt index 1ec59f65..8111bf9d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/Kitsu.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/Kitsu.kt @@ -1,9 +1,9 @@ package suwayomi.tachidesk.manga.impl.track.tracker.kitsu import android.annotation.StringRes -import kotlinx.serialization.encodeToString +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth import kotlinx.serialization.json.Json -import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker import suwayomi.tachidesk.manga.impl.track.tracker.Tracker import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch @@ -13,7 +13,7 @@ import java.text.DecimalFormat class Kitsu( id: Int, ) : Tracker(id, "Kitsu"), - DeletableTrackService { + DeletableTracker { companion object { const val READING = 1 const val COMPLETED = 2 @@ -22,10 +22,10 @@ class Kitsu( const val PLAN_TO_READ = 5 } - override val supportsTrackDeletion: Boolean = true - override val supportsReadingDates: Boolean = true + override val supportsPrivateTracking: Boolean = true + private val json: Json by injectLazy() private val interceptor by lazy { KitsuInterceptor(this) } @@ -58,7 +58,7 @@ class Kitsu( return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } } - override fun indexToScore(index: Int): Float = if (index > 0) (index + 1) / 2.0f else 0.0f + override fun indexToScore(index: Int): Double = if (index > 0) (index + 1) / 2.0 else 0.0 override fun displayScore(track: Track): String { val df = DecimalFormat("0.#") @@ -78,7 +78,7 @@ class Kitsu( track.finished_reading_date = System.currentTimeMillis() } else { track.status = READING - if (track.last_chapter_read == 1.0f) { + if (track.last_chapter_read == 1.0) { track.started_reading_date = System.currentTimeMillis() } } @@ -98,8 +98,8 @@ class Kitsu( ): Track { val remoteTrack = api.findLibManga(track, getUserId()) return if (remoteTrack != null) { - track.copyPersonalFrom(remoteTrack) - track.media_id = remoteTrack.media_id + track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false) + track.remote_id = remoteTrack.remote_id if (track.status != COMPLETED) { track.status = if (hasReadChapters) READING else track.status @@ -108,7 +108,7 @@ class Kitsu( update(track) } else { track.status = if (hasReadChapters) READING else PLAN_TO_READ - track.score = 0.0f + track.score = 0.0 add(track) } } @@ -140,14 +140,14 @@ class Kitsu( private fun getUserId(): String = getPassword() // TODO: this seems to be called saveOAuth in other trackers - fun saveToken(oauth: OAuth?) { + fun saveToken(oauth: KitsuOAuth?) { trackPreferences.setTrackToken(this, json.encodeToString(oauth)) } // TODO: this seems to be called loadOAuth in other trackers - fun restoreToken(): OAuth? = + fun restoreToken(): KitsuOAuth? = try { - json.decodeFromString(trackPreferences.getTrackToken(this)!!) + json.decodeFromString(trackPreferences.getTrackToken(this)!!) } catch (e: Exception) { null } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt index 6aee6e40..0724f6f5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuApi.kt @@ -1,6 +1,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.kitsu import androidx.core.net.toUri +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST @@ -9,12 +10,7 @@ import eu.kanade.tachiyomi.network.jsonMime import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.lang.withIOContext import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import okhttp3.FormBody @@ -24,6 +20,11 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuAddMangaResult +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuAlgoliaSearchResult +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuCurrentUserResult +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuListSearchResult +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuSearchResult import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import uy.kohesive.injekt.injectLazy @@ -48,8 +49,9 @@ class KitsuApi( putJsonObject("data") { put("type", "libraryEntries") putJsonObject("attributes") { - put("status", track.toKitsuStatus()) + put("status", track.toApiStatus()) put("progress", track.last_chapter_read.toInt()) + put("private", track.private) } putJsonObject("relationships") { putJsonObject("user") { @@ -60,7 +62,7 @@ class KitsuApi( } putJsonObject("media") { putJsonObject("data") { - put("id", track.media_id) + put("id", track.remote_id) put("type", "manga") } } @@ -73,20 +75,13 @@ class KitsuApi( .newCall( POST( "${BASE_URL}library-entries", - headers = - headersOf( - "Content-Type", - "application/vnd.api+json", - ), - body = - data - .toString() - .toRequestBody("application/vnd.api+json".toMediaType()), + headers = headersOf("Content-Type", VND_API_JSON), + body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE), ), ).awaitSuccess() - .parseAs() + .parseAs() .let { - track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long + track.remote_id = it.data.id track } } @@ -98,37 +93,30 @@ class KitsuApi( buildJsonObject { putJsonObject("data") { put("type", "libraryEntries") - put("id", track.media_id) + put("id", track.remote_id) putJsonObject("attributes") { - put("status", track.toKitsuStatus()) + put("status", track.toApiStatus()) put("progress", track.last_chapter_read.toInt()) - put("ratingTwenty", track.toKitsuScore()) + put("ratingTwenty", track.toApiScore()) put("startedAt", KitsuDateHelper.convert(track.started_reading_date)) put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date)) + put("private", track.private) } } } - with(json) { - authClient - .newCall( - Request - .Builder() - .url("${BASE_URL}library-entries/${track.media_id}") - .headers( - headersOf( - "Content-Type", - "application/vnd.api+json", - ), - ).patch( - data.toString().toRequestBody("application/vnd.api+json".toMediaType()), - ).build(), - ).awaitSuccess() - .parseAs() - .let { - track - } - } + authClient + .newCall( + Request + .Builder() + .url("${BASE_URL}library-entries/${track.remote_id}") + .headers( + headersOf("Content-Type", VND_API_JSON), + ).patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE)) + .build(), + ).awaitSuccess() + + track } suspend fun removeLibManga(track: Track) { @@ -136,12 +124,8 @@ class KitsuApi( authClient .newCall( DELETE( - "${BASE_URL}library-entries/${track.media_id}", - headers = - headersOf( - "Content-Type", - "application/vnd.api+json", - ), + "${BASE_URL}library-entries/${track.remote_id}", + headers = headersOf("Content-Type", VND_API_JSON), ), ).awaitSuccess() } @@ -153,10 +137,9 @@ class KitsuApi( authClient .newCall(GET(ALGOLIA_KEY_URL)) .awaitSuccess() - .parseAs() + .parseAs() .let { - val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content - algoliaSearch(key, query) + algoliaSearch(it.media.key, query) } } } @@ -186,14 +169,10 @@ class KitsuApi( body = jsonObject.toString().toRequestBody(jsonMime), ), ).awaitSuccess() - .parseAs() - .let { - it["hits"]!! - .jsonArray - .map { KitsuSearchManga(it.jsonObject) } - .filter { it.subType != "novel" } - .map { it.toTrack() } - } + .parseAs() + .hits + .filter { it.subtype != "novel" } + .map { it.toTrack() } } } @@ -206,19 +185,17 @@ class KitsuApi( "${BASE_URL}library-entries" .toUri() .buildUpon() - .encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId") + .encodedQuery("filter[manga_id]=${track.remote_id}&filter[user_id]=$userId") .appendQueryParameter("include", "manga") .build() with(json) { authClient .newCall(GET(url.toString())) .awaitSuccess() - .parseAs() + .parseAs() .let { - val data = it["data"]!!.jsonArray - if (data.size > 0) { - val manga = it["included"]!!.jsonArray[0].jsonObject - KitsuLibManga(data[0].jsonObject, manga).toTrack() + if (it.data.isNotEmpty() && it.included.isNotEmpty()) { + it.firstToTrack() } else { null } @@ -232,19 +209,17 @@ class KitsuApi( "${BASE_URL}library-entries" .toUri() .buildUpon() - .encodedQuery("filter[id]=${track.media_id}") + .encodedQuery("filter[id]=${track.remote_id}") .appendQueryParameter("include", "manga") .build() with(json) { authClient .newCall(GET(url.toString())) .awaitSuccess() - .parseAs() + .parseAs() .let { - val data = it["data"]!!.jsonArray - if (data.size > 0) { - val manga = it["included"]!!.jsonArray[0].jsonObject - KitsuLibManga(data[0].jsonObject, manga).toTrack() + if (it.data.isNotEmpty() && it.included.isNotEmpty()) { + it.firstToTrack() } else { throw Exception("Could not find manga") } @@ -255,7 +230,7 @@ class KitsuApi( suspend fun login( username: String, password: String, - ): OAuth = + ): KitsuOAuth = withIOContext { val formBody: RequestBody = FormBody @@ -286,13 +261,9 @@ class KitsuApi( authClient .newCall(GET(url.toString())) .awaitSuccess() - .parseAs() - .let { - it["data"]!! - .jsonArray[0] - .jsonObject["id"]!! - .jsonPrimitive.content - } + .parseAs() + .data[0] + .id } } @@ -312,6 +283,9 @@ class KitsuApi( "%5B%22synopsis%22%2C%22averageRating%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22" + "posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D" + private const val VND_API_JSON = "application/vnd.api+json" + private val VND_JSON_MEDIA_TYPE = VND_API_JSON.toMediaType() + fun mangaUrl(remoteId: Long): String = BASE_MANGA_URL + remoteId fun refreshTokenRequest(token: String) = diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuInterceptor.kt index c5f4265e..3856f8d6 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuInterceptor.kt @@ -1,5 +1,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.kitsu +import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth +import eu.kanade.tachiyomi.data.track.kitsu.dto.isExpired import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Response @@ -14,14 +16,14 @@ class KitsuInterceptor( /** * OAuth object used for authenticated requests. */ - private var oauth: OAuth? = kitsu.restoreToken() + private var oauth: KitsuOAuth? = kitsu.restoreToken() override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu") - val refreshToken = currAuth.refresh_token!! + val refreshToken = currAuth.refreshToken!! // Refresh access token if expired. if (currAuth.isExpired()) { @@ -37,7 +39,7 @@ class KitsuInterceptor( val authRequest = originalRequest .newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .addHeader("Authorization", "Bearer ${oauth!!.accessToken}") .header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})") .header("Accept", "application/vnd.api+json") .header("Content-Type", "application/vnd.api+json") @@ -46,7 +48,7 @@ class KitsuInterceptor( return chain.proceed(authRequest) } - fun newAuth(oauth: OAuth?) { + fun newAuth(oauth: KitsuOAuth?) { this.oauth = oauth kitsu.saveToken(oauth) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuModels.kt deleted file mode 100644 index 93304cd4..00000000 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuModels.kt +++ /dev/null @@ -1,147 +0,0 @@ -package suwayomi.tachidesk.manga.impl.track.tracker.kitsu - -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.int -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long -import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager -import suwayomi.tachidesk.manga.impl.track.tracker.model.Track -import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -class KitsuSearchManga( - obj: JsonObject, -) { - val id = obj["id"]!!.jsonPrimitive.long - private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content - private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.intOrNull - val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull - val original = - try { - obj["posterImage"] - ?.jsonObject - ?.get("original") - ?.jsonPrimitive - ?.content - } catch (e: IllegalArgumentException) { - // posterImage is sometimes a jsonNull object instead - null - } - private val synopsis = obj["synopsis"]?.jsonPrimitive?.contentOrNull - private val rating = obj["averageRating"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull() - private var startDate = - obj["startDate"]?.jsonPrimitive?.contentOrNull?.let { - val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) - outputDf.format(Date(it.toLong() * 1000)) - } - private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull - - fun toTrack() = - TrackSearch.create(TrackerManager.KITSU).apply { - media_id = this@KitsuSearchManga.id - title = canonicalTitle - total_chapters = chapterCount ?: 0 - cover_url = original ?: "" - summary = synopsis ?: "" - tracking_url = KitsuApi.mangaUrl(media_id) - // score = rating ?: -1.0 - publishing_status = - if (endDate == null) { - "Publishing" - } else { - "Finished" - } - publishing_type = subType ?: "" - start_date = startDate ?: "" - } -} - -class KitsuLibManga( - obj: JsonObject, - manga: JsonObject, -) { - val id = manga["id"]!!.jsonPrimitive.int - private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content - private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.intOrNull - val type = - manga["attributes"]!! - .jsonObject["mangaType"] - ?.jsonPrimitive - ?.contentOrNull - .orEmpty() - val original = - manga["attributes"]!! - .jsonObject["posterImage"]!! - .jsonObject["original"]!! - .jsonPrimitive.content - private val synopsis = manga["attributes"]!!.jsonObject["synopsis"]!!.jsonPrimitive.content - private val startDate = - manga["attributes"]!! - .jsonObject["startDate"] - ?.jsonPrimitive - ?.contentOrNull - .orEmpty() - private val startedAt = obj["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull - private val finishedAt = obj["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull - private val libraryId = obj["id"]!!.jsonPrimitive.long - val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content - private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull - val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int - - fun toTrack() = - Track.create(TrackerManager.KITSU).apply { - media_id = libraryId - title = canonicalTitle - total_chapters = chapterCount ?: 0 - // cover_url = original - // summary = synopsis - tracking_url = KitsuApi.mangaUrl(media_id) - // publishing_status = this@KitsuLibManga.status - // publishing_type = type - // start_date = startDate - started_reading_date = KitsuDateHelper.parse(startedAt) - finished_reading_date = KitsuDateHelper.parse(finishedAt) - status = toTrackStatus() - score = ratingTwenty?.let { it.toInt() / 2.0f } ?: 0.0f - last_chapter_read = progress.toFloat() - } - - private fun toTrackStatus() = - when (status) { - "current" -> Kitsu.READING - "completed" -> Kitsu.COMPLETED - "on_hold" -> Kitsu.ON_HOLD - "dropped" -> Kitsu.DROPPED - "planned" -> Kitsu.PLAN_TO_READ - else -> throw Exception("Unknown status") - } -} - -@Serializable -data class OAuth( - val access_token: String, - val token_type: String, - val created_at: Long, - val expires_in: Long, - val refresh_token: String?, -) - -fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) - -fun Track.toKitsuStatus() = - when (status) { - Kitsu.READING -> "current" - Kitsu.COMPLETED -> "completed" - Kitsu.ON_HOLD -> "on_hold" - Kitsu.DROPPED -> "dropped" - Kitsu.PLAN_TO_READ -> "planned" - else -> throw Exception("Unknown status") - } - -fun Track.toKitsuScore(): String? = if (score > 0) (score * 2).toInt().toString() else null diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuUtils.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuUtils.kt new file mode 100644 index 00000000..9bb26b30 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/KitsuUtils.kt @@ -0,0 +1,15 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu + +import suwayomi.tachidesk.manga.impl.track.tracker.model.Track + +fun Track.toApiStatus() = + when (status) { + Kitsu.READING -> "current" + Kitsu.COMPLETED -> "completed" + Kitsu.ON_HOLD -> "on_hold" + Kitsu.DROPPED -> "dropped" + Kitsu.PLAN_TO_READ -> "planned" + else -> throw Exception("Unknown status") + } + +fun Track.toApiScore(): String? = if (score > 0) (score * 2).toInt().toString() else null diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuAddManga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuAddManga.kt new file mode 100644 index 00000000..4dc41366 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuAddManga.kt @@ -0,0 +1,13 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuAddMangaResult( + val data: KitsuAddMangaItem, +) + +@Serializable +data class KitsuAddMangaItem( + val id: Long, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuListSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuListSearch.kt new file mode 100644 index 00000000..5c481acb --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuListSearch.kt @@ -0,0 +1,82 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto + +import kotlinx.serialization.Serializable +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.Kitsu +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.KitsuApi +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.KitsuDateHelper +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch + +@Serializable +data class KitsuListSearchResult( + val data: List, + val included: List = emptyList(), +) { + fun firstToTrack(): TrackSearch { + require(data.isNotEmpty()) { "Missing User data from Kitsu" } + require(included.isNotEmpty()) { "Missing Manga data from Kitsu" } + + val userData = data[0] + val userDataAttrs = userData.attributes + val manga = included[0].attributes + + return TrackSearch.create(TrackerManager.KITSU).apply { + remote_id = userData.id + title = manga.canonicalTitle + total_chapters = manga.chapterCount ?: 0 + cover_url = manga.posterImage?.original ?: "" + summary = manga.synopsis ?: "" + tracking_url = KitsuApi.mangaUrl(remote_id) + publishing_status = manga.status + publishing_type = manga.mangaType ?: "" + start_date = userDataAttrs.startedAt ?: "" + started_reading_date = KitsuDateHelper.parse(userDataAttrs.startedAt) + finished_reading_date = KitsuDateHelper.parse(userDataAttrs.finishedAt) + status = + when (userDataAttrs.status) { + "current" -> Kitsu.READING + "completed" -> Kitsu.COMPLETED + "on_hold" -> Kitsu.ON_HOLD + "dropped" -> Kitsu.DROPPED + "planned" -> Kitsu.PLAN_TO_READ + else -> throw Exception("Unknown status") + } + score = userDataAttrs.ratingTwenty?.let { it / 2.0 } ?: 0.0 + last_chapter_read = userDataAttrs.progress.toDouble() + private = userDataAttrs.private + } + } +} + +@Serializable +data class KitsuListSearchItemData( + val id: Long, + val attributes: KitsuListSearchItemDataAttributes, +) + +@Serializable +data class KitsuListSearchItemDataAttributes( + val status: String, + val startedAt: String?, + val finishedAt: String?, + val ratingTwenty: Int?, + val progress: Int, + val private: Boolean, +) + +@Serializable +data class KitsuListSearchItemIncluded( + val id: Long, + val attributes: KitsuListSearchItemIncludedAttributes, +) + +@Serializable +data class KitsuListSearchItemIncludedAttributes( + val canonicalTitle: String, + val chapterCount: Int?, + val mangaType: String?, + val posterImage: KitsuSearchItemCover?, + val synopsis: String?, + val startDate: String?, + val status: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuOAuth.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuOAuth.kt new file mode 100644 index 00000000..c5cab234 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuOAuth.kt @@ -0,0 +1,20 @@ +package eu.kanade.tachiyomi.data.track.kitsu.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuOAuth( + @SerialName("access_token") + val accessToken: String, + @SerialName("token_type") + val tokenType: String, + @SerialName("created_at") + val createdAt: Long, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("refresh_token") + val refreshToken: String?, +) + +fun KitsuOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuSearch.kt new file mode 100644 index 00000000..c485f462 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuSearch.kt @@ -0,0 +1,54 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto + +import kotlinx.serialization.Serializable +import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager +import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.KitsuApi +import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Serializable +data class KitsuSearchResult( + val media: KitsuSearchResultData, +) + +@Serializable +data class KitsuSearchResultData( + val key: String, +) + +@Serializable +data class KitsuAlgoliaSearchResult( + val hits: List, +) + +@Serializable +data class KitsuAlgoliaSearchItem( + val id: Long, + val canonicalTitle: String, + val chapterCount: Int?, + val subtype: String?, + val posterImage: KitsuSearchItemCover?, + val synopsis: String?, + val averageRating: Double?, + val startDate: Long?, + val endDate: Long?, +) { + fun toTrack(): TrackSearch = + TrackSearch.create(TrackerManager.KITSU).apply { + remote_id = this@KitsuAlgoliaSearchItem.id + title = canonicalTitle + total_chapters = chapterCount ?: 0 + cover_url = posterImage?.original ?: "" + summary = synopsis ?: "" + tracking_url = KitsuApi.mangaUrl(remote_id) + score = averageRating ?: -1.0 + publishing_status = if (endDate == null) "Publishing" else "Finished" + publishing_type = subtype ?: "" + start_date = startDate?.let { + val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US) + outputDf.format(Date(it * 1000)) + } ?: "" + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuSearchItemCover.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuSearchItemCover.kt new file mode 100644 index 00000000..05ece602 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuSearchItemCover.kt @@ -0,0 +1,8 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuSearchItemCover( + val original: String?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuUser.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuUser.kt new file mode 100644 index 00000000..d22d6776 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/kitsu/dto/KitsuUser.kt @@ -0,0 +1,13 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class KitsuCurrentUserResult( + val data: List, +) + +@Serializable +data class KitsuUser( + val id: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt index 639aab84..417288fa 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt @@ -1,9 +1,9 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates -import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker import suwayomi.tachidesk.manga.impl.track.tracker.Tracker -import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.ListItem -import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Rating +import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUListItem +import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MURating import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.copyTo import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.toTrackSearch import suwayomi.tachidesk.manga.impl.track.tracker.model.Track @@ -12,7 +12,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch class MangaUpdates( id: Int, ) : Tracker(id, "MangaUpdates"), - DeletableTrackService { + DeletableTracker { companion object { const val READING_LIST = 0 const val WISH_LIST = 1 @@ -34,8 +34,6 @@ class MangaUpdates( } } - override val supportsTrackDeletion: Boolean = true - private val interceptor by lazy { MangaUpdatesInterceptor(this) } private val api by lazy { MangaUpdatesApi(interceptor, client) } @@ -62,7 +60,7 @@ class MangaUpdates( override fun getScoreList(): List = SCORE_LIST - override fun indexToScore(index: Int): Float = if (index == 0) 0f else SCORE_LIST[index].toFloat() + override fun indexToScore(index: Int): Double = if (index == 0) 0.0 else SCORE_LIST[index].toDouble() override fun displayScore(track: Track): String = track.score.toString() @@ -88,8 +86,8 @@ class MangaUpdates( try { val (series, rating) = api.getSeriesListItem(track) track.copyFrom(series, rating) - } catch (e: Exception) { - track.score = 0f + } catch (_: Exception) { + track.score = 0.0 api.addSeriesToList(track, hasReadChapters) track } @@ -107,12 +105,12 @@ class MangaUpdates( } private fun Track.copyFrom( - item: ListItem, - rating: Rating?, + item: MUListItem, + rating: MURating?, ): Track = apply { item.copyTo(this) - score = rating?.rating ?: 0f + score = rating?.rating ?: 0.0 } override suspend fun login( @@ -124,5 +122,5 @@ class MangaUpdates( interceptor.newAuth(authenticated.sessionToken) } - fun restoreSession(): String? = trackPreferences.getTrackPassword(this) + fun restoreSession(): String? = trackPreferences.getTrackPassword(this)?.ifBlank { null } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesApi.kt index 2eee9d2b..9f10b594 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesApi.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesApi.kt @@ -7,14 +7,10 @@ import eu.kanade.tachiyomi.network.PUT import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.add import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import okhttp3.MediaType.Companion.toMediaType @@ -22,10 +18,12 @@ import okhttp3.OkHttpClient import okhttp3.RequestBody.Companion.toRequestBody import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Companion.READING_LIST import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Companion.WISH_LIST -import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Context -import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.ListItem -import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Rating -import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Record +import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUContext +import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUListItem +import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MULoginResponse +import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MURating +import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MURecord +import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUSearchResult import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import uy.kohesive.injekt.injectLazy @@ -35,9 +33,6 @@ class MangaUpdatesApi( ) { private val json: Json by injectLazy() - private val baseUrl = "https://api.mangaupdates.com" - private val contentType = "application/vnd.api+json".toMediaType() - private val authClient by lazy { client .newBuilder() @@ -45,13 +40,13 @@ class MangaUpdatesApi( .build() } - suspend fun getSeriesListItem(track: Track): Pair { + suspend fun getSeriesListItem(track: Track): Pair { val listItem = with(json) { authClient - .newCall(GET("$baseUrl/v1/lists/series/${track.media_id}")) + .newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}")) .awaitSuccess() - .parseAs() + .parseAs() } val rating = getSeriesRating(track) @@ -68,7 +63,7 @@ class MangaUpdatesApi( buildJsonArray { addJsonObject { putJsonObject("series") { - put("id", track.media_id) + put("id", track.remote_id) } put("list_id", status) } @@ -76,14 +71,14 @@ class MangaUpdatesApi( authClient .newCall( POST( - url = "$baseUrl/v1/lists/series", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/lists/series", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ).awaitSuccess() .let { if (it.code == 200) { track.status = status - track.last_chapter_read = 1f + track.last_chapter_read = 1.0 } } } @@ -93,7 +88,7 @@ class MangaUpdatesApi( buildJsonArray { addJsonObject { putJsonObject("series") { - put("id", track.media_id) + put("id", track.remote_id) } put("list_id", track.status) putJsonObject("status") { @@ -104,8 +99,8 @@ class MangaUpdatesApi( authClient .newCall( POST( - url = "$baseUrl/v1/lists/series/update", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/lists/series/update", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ).awaitSuccess() @@ -115,31 +110,32 @@ class MangaUpdatesApi( suspend fun deleteSeriesFromList(track: Track) { val body = buildJsonArray { - add(track.media_id) + add(track.remote_id) } authClient .newCall( POST( - url = "$baseUrl/v1/lists/series/delete", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/lists/series/delete", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ).awaitSuccess() } - private suspend fun getSeriesRating(track: Track): Rating? = + private suspend fun getSeriesRating(track: Track): MURating? = try { with(json) { authClient - .newCall(GET("$baseUrl/v1/series/${track.media_id}/rating")) + .newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating")) .awaitSuccess() - .parseAs() + .parseAs() } - } catch (e: Exception) { + } catch (_: Exception) { null } private suspend fun updateSeriesRating(track: Track) { - if (track.score != 0f) { + if (track.score < 0.0) return + if (track.score != 0.0) { val body = buildJsonObject { put("rating", track.score) @@ -147,21 +143,19 @@ class MangaUpdatesApi( authClient .newCall( PUT( - url = "$baseUrl/v1/series/${track.media_id}/rating", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/series/${track.remote_id}/rating", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ).awaitSuccess() } else { authClient .newCall( - DELETE( - url = "$baseUrl/v1/series/${track.media_id}/rating", - ), + DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"), ).awaitSuccess() } } - suspend fun search(query: String): List { + suspend fun search(query: String): List { val body = buildJsonObject { put("search", query) @@ -173,27 +167,25 @@ class MangaUpdatesApi( }, ) } + return with(json) { client .newCall( POST( - url = "$baseUrl/v1/series/search", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/series/search", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ).awaitSuccess() - .parseAs() - .let { obj -> - obj["results"]?.jsonArray?.map { element -> - json.decodeFromJsonElement(element.jsonObject["record"]!!) - } - }.orEmpty() + .parseAs() + .results + .map { it.record } } } suspend fun authenticate( username: String, password: String, - ): Context? { + ): MUContext? { val body = buildJsonObject { put("username", username) @@ -203,19 +195,18 @@ class MangaUpdatesApi( client .newCall( PUT( - url = "$baseUrl/v1/account/login", - body = body.toString().toRequestBody(contentType), + url = "$BASE_URL/v1/account/login", + body = body.toString().toRequestBody(CONTENT_TYPE), ), ).awaitSuccess() - .parseAs() - .let { obj -> - try { - json.decodeFromJsonElement(obj["context"]!!) - } catch (e: Exception) { - // logcat(LogPriority.ERROR, e) - null - } - } + .parseAs() + .context } } + + companion object { + private const val BASE_URL = "https://api.mangaupdates.com" + + private val CONTENT_TYPE = "application/vnd.api+json".toMediaType() + } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt index d4ea5c29..ba9f5e20 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt @@ -20,7 +20,7 @@ class MangaUpdatesInterceptor( originalRequest .newBuilder() .addHeader("Authorization", "Bearer $token") - .header("User-Agent", "Suwayomi ${BuildConfig.VERSION}") + .header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})") .build() return chain.proceed(authRequest) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Context.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUContext.kt similarity index 91% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Context.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUContext.kt index 9c5690a6..9de18485 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Context.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUContext.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -data class Context( +data class MUContext( @SerialName("session_token") val sessionToken: String, val uid: Long, diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Image.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUImage.kt similarity index 79% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Image.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUImage.kt index 907564fe..e4998154 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Image.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUImage.kt @@ -3,8 +3,8 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto import kotlinx.serialization.Serializable @Serializable -data class Image( - val url: Url? = null, +data class MUImage( + val url: MUUrl? = null, val height: Int? = null, val width: Int? = null, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/ListItem.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUListItem.kt similarity index 78% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/ListItem.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUListItem.kt index 00d227a5..3570e89d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/ListItem.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUListItem.kt @@ -6,16 +6,16 @@ import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Com import suwayomi.tachidesk.manga.impl.track.tracker.model.Track @Serializable -data class ListItem( - val series: Series? = null, +data class MUListItem( + val series: MUSeries? = null, @SerialName("list_id") val listId: Int? = null, - val status: Status? = null, + val status: MUStatus? = null, val priority: Int? = null, ) -fun ListItem.copyTo(track: Track): Track = +fun MUListItem.copyTo(track: Track): Track = track.apply { this.status = listId ?: READING_LIST - this.last_chapter_read = this@copyTo.status?.chapter?.toFloat() ?: 0f + this.last_chapter_read = this@copyTo.status?.chapter?.toDouble() ?: 0.0 } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MULoginResponse.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MULoginResponse.kt new file mode 100644 index 00000000..c1ca2a12 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MULoginResponse.kt @@ -0,0 +1,8 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MULoginResponse( + val context: MUContext, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Rating.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MURating.kt similarity index 62% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Rating.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MURating.kt index b8dff45d..c20d05ce 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Rating.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MURating.kt @@ -4,11 +4,11 @@ import kotlinx.serialization.Serializable import suwayomi.tachidesk.manga.impl.track.tracker.model.Track @Serializable -data class Rating( - val rating: Float? = null, +data class MURating( + val rating: Double? = null, ) -fun Rating.copyTo(track: Track): Track = +fun MURating.copyTo(track: Track): Track = track.apply { - this.score = rating ?: 0f + this.score = rating ?: 0.0 } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Record.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MURecord.kt similarity index 82% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Record.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MURecord.kt index 52aef3a2..144465ef 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Record.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MURecord.kt @@ -2,17 +2,17 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.jsoup.Jsoup +import suwayomi.tachidesk.manga.impl.track.Track.htmlDecode import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch @Serializable -data class Record( +data class MURecord( @SerialName("series_id") val seriesId: Long? = null, val title: String? = null, val url: String? = null, val description: String? = null, - val image: Image? = null, + val image: MUImage? = null, val type: String? = null, val year: String? = null, @SerialName("bayesian_rating") @@ -23,11 +23,9 @@ data class Record( val latestChapter: Int? = null, ) -private fun String.htmlDecode(): String = Jsoup.parse(this).wholeText() - -fun Record.toTrackSearch(id: Int): TrackSearch = +fun MURecord.toTrackSearch(id: Int): TrackSearch = TrackSearch.create(id).apply { - media_id = this@toTrackSearch.seriesId ?: 0L + remote_id = this@toTrackSearch.seriesId ?: 0L title = this@toTrackSearch.title?.htmlDecode() ?: "" total_chapters = 0 cover_url = this@toTrackSearch.image?.url?.original ?: "" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUSearch.kt new file mode 100644 index 00000000..2946ab2c --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUSearch.kt @@ -0,0 +1,13 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MUSearchResult( + val results: List, +) + +@Serializable +data class MUSearchResultItem( + val record: MURecord, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Series.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUSeries.kt similarity index 89% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Series.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUSeries.kt index ffd6ec4d..2879f01c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Series.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUSeries.kt @@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto import kotlinx.serialization.Serializable @Serializable -data class Series( +data class MUSeries( val id: Long? = null, val title: String? = null, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Status.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUStatus.kt similarity index 89% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Status.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUStatus.kt index 3d5ed016..e9dd7821 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Status.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUStatus.kt @@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto import kotlinx.serialization.Serializable @Serializable -data class Status( +data class MUStatus( val volume: Int? = null, val chapter: Int? = null, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Url.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUUrl.kt similarity index 91% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Url.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUUrl.kt index 876db261..7fd9cc61 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Url.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/MUUrl.kt @@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto import kotlinx.serialization.Serializable @Serializable -data class Url( +data class MUUrl( val original: String? = null, val thumb: String? = null, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/Track.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/Track.kt index 4dedb0fd..5cbb553d 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/Track.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/Track.kt @@ -9,19 +9,19 @@ interface Track : Serializable { var manga_id: Int - var sync_id: Int + var tracker_id: Int - var media_id: Long + var remote_id: Long var library_id: Long? var title: String - var last_chapter_read: Float + var last_chapter_read: Double var total_chapters: Int - var score: Float + var score: Double var status: Int @@ -31,18 +31,24 @@ interface Track : Serializable { var tracking_url: String - fun copyPersonalFrom(other: Track) { + var private: Boolean + + fun copyPersonalFrom( + other: Track, + copyRemotePrivate: Boolean = true, + ) { last_chapter_read = other.last_chapter_read score = other.score status = other.status started_reading_date = other.started_reading_date finished_reading_date = other.finished_reading_date + if (copyRemotePrivate) private = other.private } companion object { fun create(serviceId: Int): Track = TrackImpl().apply { - sync_id = serviceId + tracker_id = serviceId } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackConvertor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackConvertor.kt index 3afab767..a6fb389b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackConvertor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackConvertor.kt @@ -6,6 +6,7 @@ import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass import suwayomi.tachidesk.manga.model.table.TrackRecordTable import suwayomi.tachidesk.manga.model.table.TrackRecordTable.lastChapterRead import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl +import suwayomi.tachidesk.manga.model.table.TrackSearchTable fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass = TrackRecordDataClass( @@ -22,62 +23,89 @@ fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass = remoteUrl = this[TrackRecordTable.remoteUrl], startDate = this[TrackRecordTable.startDate], finishDate = this[TrackRecordTable.finishDate], + private = this[TrackRecordTable.private], ) fun ResultRow.toTrack(): Track = Track.create(this[TrackRecordTable.trackerId]).also { it.id = this[TrackRecordTable.id].value it.manga_id = this[TrackRecordTable.mangaId].value - it.media_id = this[TrackRecordTable.remoteId] + it.remote_id = this[TrackRecordTable.remoteId] it.library_id = this[TrackRecordTable.libraryId] it.title = this[TrackRecordTable.title] - it.last_chapter_read = this[TrackRecordTable.lastChapterRead].toFloat() + it.last_chapter_read = this[TrackRecordTable.lastChapterRead] it.total_chapters = this[TrackRecordTable.totalChapters] it.status = this[TrackRecordTable.status] - it.score = this[TrackRecordTable.score].toFloat() + it.score = this[TrackRecordTable.score] it.tracking_url = this[TrackRecordTable.remoteUrl] it.started_reading_date = this[TrackRecordTable.startDate] it.finished_reading_date = this[TrackRecordTable.finishDate] + it.private = this[TrackRecordTable.private] + } + +fun ResultRow.toTrackSearch(): TrackSearch = + TrackSearch.create(this[TrackSearchTable.trackerId]).also { + it.id = this[TrackSearchTable.id].value + it.remote_id = this[TrackSearchTable.remoteId] + it.library_id = this[TrackSearchTable.libraryId] + it.title = this[TrackSearchTable.title] + it.last_chapter_read = this[TrackSearchTable.lastChapterRead] + it.total_chapters = this[TrackSearchTable.totalChapters] + it.status = this[TrackSearchTable.status] + it.score = this[TrackSearchTable.score] + it.tracking_url = this[TrackSearchTable.trackingUrl] + it.started_reading_date = this[TrackSearchTable.startedReadingDate] + it.finished_reading_date = this[TrackSearchTable.finishedReadingDate] + it.private = this[TrackSearchTable.private] + it.authors = this[TrackSearchTable.authors]?.split(",").orEmpty() + it.artists = this[TrackSearchTable.artists]?.split(",").orEmpty() + it.cover_url = this[TrackSearchTable.coverUrl] + it.summary = this[TrackSearchTable.summary] + it.publishing_status = this[TrackSearchTable.publishingStatus] + it.publishing_type = this[TrackSearchTable.publishingType] + it.start_date = this[TrackSearchTable.startDate] } fun BackupTracking.toTrack(mangaId: Int): Track = Track.create(syncId).also { it.id = -1 it.manga_id = mangaId - it.media_id = mediaId + it.remote_id = mediaId it.library_id = libraryId it.title = title - it.last_chapter_read = lastChapterRead + it.last_chapter_read = lastChapterRead.toDouble() it.total_chapters = totalChapters it.status = status - it.score = score + it.score = score.toDouble() it.tracking_url = trackingUrl it.started_reading_date = startedReadingDate it.finished_reading_date = finishedReadingDate + it.private = private } fun TrackRecordDataClass.toTrack(): Track = Track.create(trackerId).also { it.id = id it.manga_id = mangaId - it.media_id = remoteId + it.remote_id = remoteId it.library_id = libraryId it.title = title - it.last_chapter_read = lastChapterRead.toFloat() + it.last_chapter_read = lastChapterRead it.total_chapters = totalChapters it.status = status - it.score = score.toFloat() + it.score = score it.tracking_url = remoteUrl it.started_reading_date = startDate it.finished_reading_date = finishDate + it.private = private } fun Track.toTrackRecordDataClass(): TrackRecordDataClass = TrackRecordDataClass( id = id ?: -1, mangaId = manga_id, - trackerId = sync_id, - remoteId = media_id, + trackerId = tracker_id, + remoteId = remote_id, libraryId = library_id, title = title, lastChapterRead = last_chapter_read.toDouble(), @@ -87,4 +115,5 @@ fun Track.toTrackRecordDataClass(): TrackRecordDataClass = remoteUrl = tracking_url, startDate = started_reading_date, finishDate = finished_reading_date, + private = private, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackImpl.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackImpl.kt index 96bff7f3..0378ad19 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackImpl.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackImpl.kt @@ -7,19 +7,19 @@ class TrackImpl : Track { override var manga_id: Int = 0 - override var sync_id: Int = 0 + override var tracker_id: Int = 0 - override var media_id: Long = 0 + override var remote_id: Long = 0 override var library_id: Long? = null override lateinit var title: String - override var last_chapter_read: Float = 0F + override var last_chapter_read: Double = 0.0 override var total_chapters: Int = 0 - override var score: Float = 0f + override var score: Double = 0.0 override var status: Int = 0 @@ -28,4 +28,6 @@ class TrackImpl : Track { override var finished_reading_date: Long = 0 override var tracking_url: String = "" + + override var private: Boolean = false } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackSearch.kt index 38a2d1cc..8bd29bc5 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackSearch.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/model/TrackSearch.kt @@ -2,16 +2,38 @@ package suwayomi.tachidesk.manga.impl.track.tracker.model -class TrackSearch { - var sync_id: Int = 0 +class TrackSearch : Track { + override var id: Int? = null - var media_id: Long = 0 + override var manga_id: Int = 0 - lateinit var title: String + override var tracker_id: Int = 0 - var total_chapters: Int = 0 + override var remote_id: Long = 0 - lateinit var tracking_url: String + override var library_id: Long? = null + + override lateinit var title: String + + override var last_chapter_read: Double = 0.0 + + override var total_chapters: Int = 0 + + override var score: Double = -1.0 + + override var status: Int = 0 + + override var started_reading_date: Long = 0 + + override var finished_reading_date: Long = 0 + + override var private: Boolean = false + + override lateinit var tracking_url: String + + var authors: List = emptyList() + + var artists: List = emptyList() var cover_url: String = "" @@ -29,22 +51,24 @@ class TrackSearch { other as TrackSearch - if (sync_id != other.sync_id) return false - if (media_id != other.media_id) return false + if (manga_id != other.manga_id) return false + if (tracker_id != other.tracker_id) return false + if (remote_id != other.remote_id) return false return true } override fun hashCode(): Int { - var result = sync_id.hashCode() - result = 31 * result + media_id.hashCode() + var result = manga_id.hashCode() + result = 31 * result + tracker_id.hashCode() + result = 31 * result + remote_id.hashCode() return result } companion object { fun create(serviceId: Int): TrackSearch = TrackSearch().apply { - sync_id = serviceId + tracker_id = serviceId } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt index 3ae96b92..6f6363f7 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeList.kt @@ -2,20 +2,20 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist import android.annotation.StringRes import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService +import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker import suwayomi.tachidesk.manga.impl.track.tracker.Tracker import suwayomi.tachidesk.manga.impl.track.tracker.extractToken import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALOAuth import uy.kohesive.injekt.injectLazy import java.io.IOException class MyAnimeList( id: Int, ) : Tracker(id, "MyAnimeList"), - DeletableTrackService { + DeletableTracker { companion object { const val READING = 1 const val COMPLETED = 2 @@ -28,8 +28,6 @@ class MyAnimeList( private const val SEARCH_LIST_PREFIX = "my:" } - override val supportsTrackDeletion: Boolean = true - private val json: Json by injectLazy() private val interceptor by lazy { MyAnimeListInterceptor(this) } @@ -78,7 +76,7 @@ class MyAnimeList( track.finished_reading_date = System.currentTimeMillis() } else if (track.status != REREADING) { track.status = READING - if (track.last_chapter_read == 1F) { + if (track.last_chapter_read == 1.0) { track.started_reading_date = System.currentTimeMillis() } } @@ -99,18 +97,18 @@ class MyAnimeList( val remoteTrack = api.findListItem(track) return if (remoteTrack != null) { track.copyPersonalFrom(remoteTrack) - track.media_id = remoteTrack.media_id + track.remote_id = remoteTrack.remote_id if (track.status != COMPLETED) { val isRereading = track.status == REREADING - track.status = if (isRereading.not() && hasReadChapters) READING else track.status + track.status = if (!isRereading && hasReadChapters) READING else track.status } update(track) } else { // Set default fields if it's not found in the list track.status = if (hasReadChapters) READING else PLAN_TO_READ - track.score = 0F + track.score = 0.0 add(track) } } @@ -147,11 +145,10 @@ class MyAnimeList( suspend fun login(authCode: String) { try { - logger.debug { "login $authCode" } val oauth = api.getAccessToken(authCode) interceptor.setAuth(oauth) val username = api.getCurrentUser() - saveCredentials(username, oauth.access_token) + saveCredentials(username, oauth.accessToken) } catch (e: Throwable) { logger.error(e) { "oauth err" } logout() @@ -165,13 +162,13 @@ class MyAnimeList( interceptor.setAuth(null) } - fun saveOAuth(oAuth: OAuth?) { + fun saveOAuth(oAuth: MALOAuth?) { trackPreferences.setTrackToken(this, json.encodeToString(oAuth)) } - fun loadOAuth(): OAuth? = + fun loadOAuth(): MALOAuth? = try { - json.decodeFromString(trackPreferences.getTrackToken(this)!!) + json.decodeFromString(trackPreferences.getTrackToken(this)!!) } catch (e: Exception) { logger.error(e) { "loadOAuth err" } null diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListApi.kt index cca63a47..aa7fac67 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListApi.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListApi.kt @@ -2,6 +2,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist import android.net.Uri import androidx.core.net.toUri +import eu.kanade.tachiyomi.network.DELETE import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess @@ -11,15 +12,6 @@ import eu.kanade.tachiyomi.util.lang.withIOContext import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.boolean -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.float -import kotlinx.serialization.json.int -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.long import okhttp3.FormBody import okhttp3.Headers import okhttp3.OkHttpClient @@ -28,6 +20,13 @@ import okhttp3.RequestBody import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALListItem +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALListItemStatus +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALManga +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALOAuth +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALSearchResult +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALUser +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALUserSearchResult import uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat import java.util.Locale @@ -40,7 +39,7 @@ class MyAnimeListApi( private val authClient = client.newBuilder().addInterceptor(interceptor).build() - suspend fun getAccessToken(authCode: String): OAuth = + suspend fun getAccessToken(authCode: String): MALOAuth = withIOContext { val formBody: RequestBody = FormBody @@ -70,8 +69,8 @@ class MyAnimeListApi( authClient .newCall(request) .awaitSuccess() - .parseAs() - .let { it["name"]!!.jsonPrimitive.content } + .parseAs() + .name } } @@ -89,17 +88,11 @@ class MyAnimeListApi( authClient .newCall(GET(url.toString())) .awaitSuccess() - .parseAs() - .let { - it["data"]!! - .jsonArray - .map { data -> data.jsonObject["node"]!!.jsonObject } - .map { node -> - val id = node["id"]!!.jsonPrimitive.int - async { getMangaDetails(id) } - }.awaitAll() - .filter { trackSearch -> !trackSearch.publishing_type.contains("novel") } - } + .parseAs() + .data + .map { async { getMangaDetails(it.node.id) } } + .awaitAll() + .filter { !it.publishing_type.contains("novel") } } } @@ -110,33 +103,27 @@ class MyAnimeListApi( .toUri() .buildUpon() .appendPath(id.toString()) - .appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date") - .build() + .appendQueryParameter( + "fields", + "id,title,synopsis,num_chapters,mean,main_picture,status,media_type,start_date", + ).build() with(json) { authClient .newCall(GET(url.toString())) .awaitSuccess() - .parseAs() + .parseAs() .let { - val obj = it.jsonObject TrackSearch.create(TrackerManager.MYANIMELIST).apply { - media_id = obj["id"]!!.jsonPrimitive.long - title = obj["title"]!!.jsonPrimitive.content - summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" - total_chapters = obj["num_chapters"]!!.jsonPrimitive.int - cover_url = - obj["main_picture"] - ?.jsonObject - ?.get("large") - ?.jsonPrimitive - ?.content - ?: "" - tracking_url = "https://myanimelist.net/manga/$media_id" - publishing_status = - obj["status"]!!.jsonPrimitive.content.replace("_", " ") - publishing_type = - obj["media_type"]!!.jsonPrimitive.content.replace("_", " ") - start_date = obj["start_date"]?.jsonPrimitive?.content ?: "" + remote_id = it.id + title = it.title + summary = it.synopsis + total_chapters = it.numChapters + score = it.mean + cover_url = it.covers?.large.orEmpty() + tracking_url = "https://myanimelist.net/manga/$remote_id" + publishing_status = it.status.replace("_", " ") + publishing_type = it.mediaType.replace("_", " ") + start_date = it.startDate ?: "" } } } @@ -161,30 +148,25 @@ class MyAnimeListApi( val request = Request .Builder() - .url(mangaUrl(track.media_id).toString()) + .url(mangaUrl(track.remote_id).toString()) .put(formBodyBuilder.build()) .build() with(json) { authClient .newCall(request) .awaitSuccess() - .parseAs() + .parseAs() .let { parseMangaItem(it, track) } } } - suspend fun deleteItem(track: Track) = + suspend fun deleteItem(track: Track) { withIOContext { - val request = - Request - .Builder() - .url(mangaUrl(track.media_id).toString()) - .delete() - .build() authClient - .newCall(request) + .newCall(DELETE(mangaUrl(track.remote_id).toString())) .awaitSuccess() } + } suspend fun findListItem(track: Track): Track? = withIOContext { @@ -192,19 +174,17 @@ class MyAnimeListApi( "$BASE_API_URL/manga" .toUri() .buildUpon() - .appendPath(track.media_id.toString()) + .appendPath(track.remote_id.toString()) .appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}") .build() with(json) { authClient .newCall(GET(uri.toString())) .awaitSuccess() - .parseAs() - .let { obj -> - track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int - obj.jsonObject["my_list_status"]?.jsonObject?.let { - parseMangaItem(it, track) - } + .parseAs() + .let { item -> + track.total_chapters = item.numChapters + item.myListStatus?.let { parseMangaItem(it, track) } } } } @@ -214,39 +194,23 @@ class MyAnimeListApi( offset: Int = 0, ): List = withIOContext { - val json = getListPage(offset) - val obj = json.jsonObject + val myListSearchResult = getListPage(offset) val matches = - obj["data"]!! - .jsonArray - .filter { - it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains( - query, - ignoreCase = true, - ) - }.map { - val id = - it.jsonObject["node"]!! - .jsonObject["id"]!! - .jsonPrimitive.int - async { getMangaDetails(id) } - }.awaitAll() + myListSearchResult.data + .filter { it.node.title.contains(query, ignoreCase = true) } + .map { async { getMangaDetails(it.node.id) } } + .awaitAll() // Check next page if there's more - if (!obj["paging"]!! - .jsonObject["next"] - ?.jsonPrimitive - ?.contentOrNull - .isNullOrBlank() - ) { + if (!myListSearchResult.paging.next.isNullOrBlank()) { matches + findListItems(query, offset + LIST_PAGINATION_AMOUNT) } else { matches } } - private suspend fun getListPage(offset: Int): JsonObject = + private suspend fun getListPage(offset: Int): MALUserSearchResult = withIOContext { val urlBuilder = "$BASE_API_URL/users/@me/mangalist" @@ -273,23 +237,17 @@ class MyAnimeListApi( } private fun parseMangaItem( - response: JsonObject, + listStatus: MALListItemStatus, track: Track, - ): Track { - val obj = response.jsonObject - return track.apply { - val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean - status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]?.jsonPrimitive?.content) - last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.float - score = obj["score"]!!.jsonPrimitive.int.toFloat() - obj["start_date"]?.let { - started_reading_date = parseDate(it.jsonPrimitive.content) - } - obj["finish_date"]?.let { - finished_reading_date = parseDate(it.jsonPrimitive.content) - } + ): Track = + track.apply { + val isRereading = listStatus.isRereading + status = if (isRereading) MyAnimeList.REREADING else getStatus(listStatus.status) + last_chapter_read = listStatus.numChaptersRead + score = listStatus.score.toDouble() + listStatus.startDate?.let { started_reading_date = parseDate(it) } + listStatus.finishDate?.let { finished_reading_date = parseDate(it) } } - } private fun parseDate(isoDate: String): Long = SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L @@ -332,12 +290,12 @@ class MyAnimeListApi( .appendPath("my_list_status") .build() - fun refreshTokenRequest(oauth: OAuth): Request { + fun refreshTokenRequest(oauth: MALOAuth): Request { val formBody: RequestBody = FormBody .Builder() .add("client_id", CLIENT_ID) - .add("refresh_token", oauth.refresh_token) + .add("refresh_token", oauth.refreshToken) .add("grant_type", "refresh_token") .build() @@ -347,7 +305,7 @@ class MyAnimeListApi( val headers = Headers .Builder() - .add("Authorization", "Bearer ${oauth.access_token}") + .add("Authorization", "Bearer ${oauth.accessToken}") .build() return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt index 80ca9929..189e281c 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt @@ -5,8 +5,7 @@ import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Response -import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired -import suwayomi.tachidesk.manga.impl.track.tracker.TokenRefreshFailed +import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALOAuth import uy.kohesive.injekt.injectLazy import java.io.IOException @@ -15,11 +14,12 @@ class MyAnimeListInterceptor( ) : Interceptor { private val json: Json by injectLazy() - private var oauth: OAuth? = myanimelist.loadOAuth() + private var oauth: MALOAuth? = myanimelist.loadOAuth() + private val tokenExpired get() = myanimelist.getIfAuthExpired() override fun intercept(chain: Interceptor.Chain): Response { - if (myanimelist.getIfAuthExpired()) { - throw TokenExpired() + if (tokenExpired) { + throw MALTokenExpired() } val originalRequest = chain.request() @@ -35,7 +35,7 @@ class MyAnimeListInterceptor( val authRequest = originalRequest .newBuilder() - .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .addHeader("Authorization", "Bearer ${oauth!!.accessToken}") .header("User-Agent", "Suwayomi v${AppInfo.getVersionName()}") .build() @@ -46,37 +46,44 @@ class MyAnimeListInterceptor( * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token * and the oauth object. */ - fun setAuth(oauth: OAuth?) { + fun setAuth(oauth: MALOAuth?) { this.oauth = oauth myanimelist.saveOAuth(oauth) } - private fun refreshToken(chain: Interceptor.Chain): OAuth = + private fun refreshToken(chain: Interceptor.Chain): MALOAuth = synchronized(this) { - if (myanimelist.getIfAuthExpired()) throw TokenExpired() + if (tokenExpired) throw MALTokenExpired() oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it } val response = try { chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!)) } catch (_: Throwable) { - throw TokenRefreshFailed() + throw MALTokenRefreshFailed() } if (response.code == 401) { myanimelist.setAuthExpired() - throw TokenExpired() + throw MALTokenExpired() } return runCatching { if (response.isSuccessful) { - with(json) { response.parseAs() } + with(json) { response.parseAs() } } else { response.close() null } }.getOrNull() - ?.also(::setAuth) - ?: throw TokenRefreshFailed() + ?.also { + this.oauth = it + myanimelist.saveOAuth(it) + } + ?: throw MALTokenRefreshFailed() } } + +class MALTokenRefreshFailed : IOException("MAL: Failed to refresh account token") + +class MALTokenExpired : IOException("MAL: Login has expired") diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListModels.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListUtils.kt similarity index 70% rename from server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListModels.kt rename to server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListUtils.kt index caf08435..6cd1924f 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListModels.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListUtils.kt @@ -1,19 +1,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist -import kotlinx.serialization.Serializable import suwayomi.tachidesk.manga.impl.track.tracker.model.Track -@Serializable -data class OAuth( - val refresh_token: String, - val access_token: String, - val token_type: String, - val created_at: Long = System.currentTimeMillis(), - val expires_in: Long, -) - -fun OAuth.isExpired() = System.currentTimeMillis() > created_at + (expires_in * 1000) - fun Track.toMyAnimeListStatus() = when (status) { MyAnimeList.READING -> "reading" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALList.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALList.kt new file mode 100644 index 00000000..5e1966c1 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALList.kt @@ -0,0 +1,26 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MALListItem( + @SerialName("num_chapters") + val numChapters: Int, + @SerialName("my_list_status") + val myListStatus: MALListItemStatus?, +) + +@Serializable +data class MALListItemStatus( + @SerialName("is_rereading") + val isRereading: Boolean, + val status: String, + @SerialName("num_chapters_read") + val numChaptersRead: Double, + val score: Int, + @SerialName("start_date") + val startDate: String?, + @SerialName("finish_date") + val finishDate: String?, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALManga.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALManga.kt new file mode 100644 index 00000000..2a06f696 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALManga.kt @@ -0,0 +1,26 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MALManga( + val id: Long, + val title: String, + val synopsis: String = "", + @SerialName("num_chapters") + val numChapters: Int, + val mean: Double = -1.0, + @SerialName("main_picture") + val covers: MALMangaCovers?, + val status: String, + @SerialName("media_type") + val mediaType: String, + @SerialName("start_date") + val startDate: String?, +) + +@Serializable +data class MALMangaCovers( + val large: String = "", +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALOAuth.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALOAuth.kt new file mode 100644 index 00000000..24d8cb35 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALOAuth.kt @@ -0,0 +1,25 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto + +import kotlinx.serialization.EncodeDefault +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class MALOAuth( + @SerialName("token_type") + val tokenType: String, + @SerialName("refresh_token") + val refreshToken: String, + @SerialName("access_token") + val accessToken: String, + @SerialName("expires_in") + val expiresIn: Long, + @SerialName("created_at") + @EncodeDefault + val createdAt: Long = System.currentTimeMillis() / 1000, +) { + // Assumes expired a minute earlier + private val adjustedExpiresIn: Long = (expiresIn - 60) + + fun isExpired() = createdAt + adjustedExpiresIn < System.currentTimeMillis() / 1000 +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALSearch.kt new file mode 100644 index 00000000..2e8db5e4 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALSearch.kt @@ -0,0 +1,18 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MALSearchResult( + val data: List, +) + +@Serializable +data class MALSearchResultNode( + val node: MALSearchResultItem, +) + +@Serializable +data class MALSearchResultItem( + val id: Int, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALUser.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALUser.kt new file mode 100644 index 00000000..622514f6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALUser.kt @@ -0,0 +1,8 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MALUser( + val name: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALUserListSearch.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALUserListSearch.kt new file mode 100644 index 00000000..6377316b --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/dto/MALUserListSearch.kt @@ -0,0 +1,25 @@ +package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class MALUserSearchResult( + val data: List, + val paging: MALUserSearchPaging, +) + +@Serializable +data class MALUserSearchItem( + val node: MALUserSearchItemNode, +) + +@Serializable +data class MALUserSearchPaging( + val next: String?, +) + +@Serializable +data class MALUserSearchItemNode( + val id: Int, + val title: String, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackRecordDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackRecordDataClass.kt index c1d9b448..b2ca15ca 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackRecordDataClass.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackRecordDataClass.kt @@ -22,4 +22,5 @@ data class TrackRecordDataClass( val remoteUrl: String, val startDate: Long, val finishDate: Long, + val private: Boolean, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackSearchDataClass.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackSearchDataClass.kt index e2814cf6..9b291f60 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackSearchDataClass.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/dataclass/TrackSearchDataClass.kt @@ -14,7 +14,9 @@ data class TrackSearchDataClass( val id: Int, val trackerId: Int, val remoteId: Long, + val libraryId: Long?, val title: String, + val lastChapterRead: Double, val totalChapters: Int, val trackingUrl: String, val coverUrl: String, @@ -22,4 +24,10 @@ data class TrackSearchDataClass( val publishingStatus: String, val publishingType: String, val startDate: String, + val status: Int, + val score: Double, + var scoreString: String?, + val startedReadingDate: Long, + val finishedReadingDate: Long, + val private: Boolean, ) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt index ced2b5d0..392ab5cb 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackRecordTable.kt @@ -24,4 +24,5 @@ object TrackRecordTable : IntIdTable() { val remoteUrl = varchar("remote_url", 512) val startDate = long("start_date") val finishDate = long("finish_date") + val private = bool("private").default(false) } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackSearchTable.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackSearchTable.kt index 81c68bff..32572d5b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackSearchTable.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/model/table/TrackSearchTable.kt @@ -29,13 +29,22 @@ object TrackSearchTable : IntIdTable() { val publishingStatus = truncatingVarchar("publishing_status", 512) val publishingType = truncatingVarchar("publishing_type", 512) val startDate = truncatingVarchar("start_date", 128) + val libraryId = long("library_id").nullable().default(null) + val lastChapterRead = double("last_chapter_read").default(0.0) + val status = integer("status").default(0) + val score = double("score").default(0.0) + val startedReadingDate = long("started_reading_date").default(0) + val finishedReadingDate = long("finished_reading_date").default(0) + val private = bool("private").default(false) + val authors = truncatingVarchar("authors", 256).nullable().default(null) + val artists = truncatingVarchar("artists", 256).nullable().default(null) } fun List.insertAll(): List { if (isEmpty()) return emptyList() return transaction { - val trackerIds = map { it.sync_id }.toSet() - val remoteIds = map { it.media_id }.toSet() + val trackerIds = map { it.tracker_id }.toSet() + val remoteIds = map { it.remote_id }.toSet() val existing = transaction { TrackSearchTable @@ -50,8 +59,8 @@ fun List.insertAll(): List { forEach { trackSearch -> val existingRow = existing.find { - it[TrackSearchTable.trackerId] == trackSearch.sync_id && - it[TrackSearchTable.remoteId] == trackSearch.media_id + it[TrackSearchTable.trackerId] == trackSearch.tracker_id && + it[TrackSearchTable.remoteId] == trackSearch.remote_id } grouped .getOrPut(existingRow != null) { mutableListOf() } @@ -72,6 +81,15 @@ fun List.insertAll(): List { this[TrackSearchTable.publishingStatus] = trackSearch.publishing_status this[TrackSearchTable.publishingType] = trackSearch.publishing_type this[TrackSearchTable.startDate] = trackSearch.start_date + this[TrackSearchTable.libraryId] = trackSearch.library_id + this[TrackSearchTable.lastChapterRead] = trackSearch.last_chapter_read + this[TrackSearchTable.status] = trackSearch.status + this[TrackSearchTable.score] = trackSearch.score + this[TrackSearchTable.startedReadingDate] = trackSearch.started_reading_date + this[TrackSearchTable.finishedReadingDate] = trackSearch.finished_reading_date + this[TrackSearchTable.private] = trackSearch.private + this[TrackSearchTable.authors] = trackSearch.authors.ifEmpty { null }?.joinToString(",") + this[TrackSearchTable.artists] = trackSearch.artists.ifEmpty { null }?.joinToString(",") } execute(this@transaction) } @@ -79,8 +97,8 @@ fun List.insertAll(): List { val insertedRows = if (!toInsert.isNullOrEmpty()) { TrackSearchTable.batchInsert(toInsert) { - this[TrackSearchTable.trackerId] = it.sync_id - this[TrackSearchTable.remoteId] = it.media_id + this[TrackSearchTable.trackerId] = it.tracker_id + this[TrackSearchTable.remoteId] = it.remote_id this[TrackSearchTable.title] = it.title this[TrackSearchTable.totalChapters] = it.total_chapters this[TrackSearchTable.trackingUrl] = it.tracking_url @@ -89,6 +107,15 @@ fun List.insertAll(): List { this[TrackSearchTable.publishingStatus] = it.publishing_status this[TrackSearchTable.publishingType] = it.publishing_type this[TrackSearchTable.startDate] = it.start_date + this[TrackSearchTable.libraryId] = it.library_id + this[TrackSearchTable.lastChapterRead] = it.last_chapter_read + this[TrackSearchTable.status] = it.status + this[TrackSearchTable.score] = it.score + this[TrackSearchTable.startedReadingDate] = it.started_reading_date + this[TrackSearchTable.finishedReadingDate] = it.finished_reading_date + this[TrackSearchTable.private] = it.private + this[TrackSearchTable.authors] = it.authors.ifEmpty { null }?.joinToString(",") + this[TrackSearchTable.artists] = it.artists.ifEmpty { null }?.joinToString(",") } } else { emptyList() @@ -104,8 +131,8 @@ fun List.insertAll(): List { (insertedRows + updatedRows) .sortedBy { row -> indexOfFirst { - it.sync_id == row[TrackSearchTable.trackerId] && - it.media_id == row[TrackSearchTable.remoteId] + it.tracker_id == row[TrackSearchTable.trackerId] && + it.remote_id == row[TrackSearchTable.remoteId] } } } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0048_AddTrackingColumns.kt b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0048_AddTrackingColumns.kt new file mode 100644 index 00000000..0a797f16 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/server/database/migration/M0048_AddTrackingColumns.kt @@ -0,0 +1,29 @@ +package suwayomi.tachidesk.server.database.migration + +import de.neonew.exposed.migrations.helpers.SQLMigration + +@Suppress("ClassName", "unused") +class M0048_AddTrackingColumns : SQLMigration() { + fun createNewColumn( + tableName: String, + columnName: String, + columnType: String, + default: String, + notNull: Boolean = false, + ) = "ALTER TABLE $tableName" + + " ADD COLUMN $columnName $columnType DEFAULT $default${if (notNull) " NOT NULL" else ""};" + + override val sql: String = + """ + ${createNewColumn("TRACKRECORD", "PRIVATE", "BOOLEAN", "FALSE", notNull = true)} + ${createNewColumn("TRACKSEARCH", "LIBRARY_ID", "BIGINT", "NULL")} + ${createNewColumn("TRACKSEARCH", "LAST_CHAPTER_READ", "DOUBLE PRECISION", "0", notNull = true)} + ${createNewColumn("TRACKSEARCH", "STATUS", "INT", "0", notNull = true)} + ${createNewColumn("TRACKSEARCH", "SCORE", "DOUBLE PRECISION", "0", notNull = true)} + ${createNewColumn("TRACKSEARCH", "STARTED_READING_DATE", "BIGINT", "0", notNull = true)} + ${createNewColumn("TRACKSEARCH", "FINISHED_READING_DATE", "BIGINT", "0", notNull = true)} + ${createNewColumn("TRACKSEARCH", "PRIVATE", "BOOLEAN", "FALSE", notNull = true)} + ${createNewColumn("TRACKSEARCH", "AUTHORS", "VARCHAR(256)", "NULL")} + ${createNewColumn("TRACKSEARCH", "ARTISTS", "VARCHAR(256)", "NULL")} + """.trimIndent() +}