Update Tracking Backend (#1457)

* Update Tracking Library

* Update Bangumi

* Update Anilist

* Update MangaUpdates

* Update MAL

* Add private to bind track

* Use null

* Remove old nullable

* Remove custom implementation of supportsTrackDeletion

* Add private to updateTrack

* Some descriptions

* Another description
This commit is contained in:
Mitchell Syer
2025-06-22 10:38:22 -04:00
committed by GitHub
parent 972137c035
commit abea85d831
76 changed files with 1496 additions and 940 deletions

View File

@@ -22,7 +22,9 @@ import suwayomi.tachidesk.graphql.types.TrackStatusType
import suwayomi.tachidesk.graphql.types.TrackerType import suwayomi.tachidesk.graphql.types.TrackerType
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager 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.toTrack
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrackSearch
import suwayomi.tachidesk.manga.model.table.TrackRecordTable import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import suwayomi.tachidesk.manga.model.table.TrackSearchTable
import suwayomi.tachidesk.server.JavalinSetup.future import suwayomi.tachidesk.server.JavalinSetup.future
class TrackerDataLoader : KotlinDataLoader<Int, TrackerType> { class TrackerDataLoader : KotlinDataLoader<Int, TrackerType> {
@@ -116,7 +118,30 @@ class DisplayScoreForTrackRecordDataLoader : KotlinDataLoader<Int, String> {
.toList() .toList()
.map { it.toTrack() } .map { it.toTrack() }
.associateBy { it.id!! } .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<Int, String> {
override val dataLoaderName = "DisplayScoreForTrackRecordDataLoader"
override fun getDataLoader(graphQLContext: GraphQLContext): DataLoader<Int, String> =
DataLoaderFactory.newDataLoader<Int, String> { 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] } ids.map { trackRecords[it] }
} }

View File

@@ -108,6 +108,8 @@ class TrackMutation {
val mangaId: Int, val mangaId: Int,
val trackerId: Int, val trackerId: Int,
val remoteId: Long, 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( data class BindTrackPayload(
@@ -116,13 +118,14 @@ class TrackMutation {
) )
fun bindTrack(input: BindTrackInput): CompletableFuture<BindTrackPayload> { fun bindTrack(input: BindTrackInput): CompletableFuture<BindTrackPayload> {
val (clientMutationId, mangaId, trackerId, remoteId) = input val (clientMutationId, mangaId, trackerId, remoteId, private) = input
return future { return future {
Track.bind( Track.bind(
mangaId, mangaId,
trackerId, trackerId,
remoteId, remoteId,
private ?: false,
) )
val trackRecord = val trackRecord =
transaction { transaction {
@@ -238,8 +241,12 @@ class TrackMutation {
val status: Int? = null, val status: Int? = null,
val lastChapterRead: Double? = null, val lastChapterRead: Double? = null,
val scoreString: String? = null, val scoreString: String? = null,
@GraphQLDescription("This will only work if the tracker of the track record supports reading dates")
val startDate: Long? = null, val startDate: Long? = null,
@GraphQLDescription("This will only work if the tracker of the track record supports reading dates")
val finishDate: Long? = null, 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")) @GraphQLDeprecated("Replaced with \"unbindTrack\" mutation", replaceWith = ReplaceWith("unbindTrack"))
val unbind: Boolean? = null, val unbind: Boolean? = null,
) )
@@ -260,6 +267,7 @@ class TrackMutation {
input.startDate, input.startDate,
input.finishDate, input.finishDate,
input.unbind, input.unbind,
input.private,
), ),
) )

View File

@@ -17,6 +17,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoader import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
@@ -83,6 +84,7 @@ class TachideskDataLoaderRegistryFactory {
TrackerTokenExpiredDataLoader(), TrackerTokenExpiredDataLoader(),
TrackRecordsForMangaIdDataLoader(), TrackRecordsForMangaIdDataLoader(),
DisplayScoreForTrackRecordDataLoader(), DisplayScoreForTrackRecordDataLoader(),
DisplayScoreForTrackSearchDataLoader(),
TrackRecordsForTrackerIdDataLoader(), TrackRecordsForTrackerIdDataLoader(),
TrackRecordDataLoader(), TrackRecordDataLoader(),
) )

View File

@@ -9,6 +9,7 @@ import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.PageInfo import suwayomi.tachidesk.graphql.server.primitives.PageInfo
import suwayomi.tachidesk.manga.impl.track.Track 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.impl.track.tracker.Tracker
import suwayomi.tachidesk.manga.model.table.TrackRecordTable import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import suwayomi.tachidesk.manga.model.table.TrackSearchTable import suwayomi.tachidesk.manga.model.table.TrackSearchTable
@@ -20,7 +21,9 @@ class TrackerType(
val icon: String, val icon: String,
val isLoggedIn: Boolean, val isLoggedIn: Boolean,
val authUrl: String?, val authUrl: String?,
val supportsTrackDeletion: Boolean?, val supportsTrackDeletion: Boolean,
val supportsReadingDates: Boolean,
val supportsPrivateTracking: Boolean,
) : Node { ) : Node {
constructor(tracker: Tracker) : this( constructor(tracker: Tracker) : this(
tracker.isLoggedIn, tracker.isLoggedIn,
@@ -37,7 +40,9 @@ class TrackerType(
} else { } else {
tracker.authUrl() tracker.authUrl()
}, },
tracker.supportsTrackDeletion, tracker is DeletableTracker,
tracker.supportsReadingDates,
tracker.supportsPrivateTracking,
) )
fun statuses(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<TrackStatusType>> = fun statuses(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<TrackStatusType>> =
@@ -72,6 +77,7 @@ class TrackRecordType(
val remoteUrl: String, val remoteUrl: String,
val startDate: Long, val startDate: Long,
val finishDate: Long, val finishDate: Long,
val private: Boolean,
) : Node { ) : Node {
constructor(row: ResultRow) : this( constructor(row: ResultRow) : this(
row[TrackRecordTable.id].value, row[TrackRecordTable.id].value,
@@ -87,6 +93,7 @@ class TrackRecordType(
row[TrackRecordTable.remoteUrl], row[TrackRecordTable.remoteUrl],
row[TrackRecordTable.startDate], row[TrackRecordTable.startDate],
row[TrackRecordTable.finishDate], row[TrackRecordTable.finishDate],
row[TrackRecordTable.private],
) )
fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<String> = fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<String> =
@@ -103,7 +110,9 @@ class TrackSearchType(
val id: Int, val id: Int,
val trackerId: Int, val trackerId: Int,
val remoteId: Long, val remoteId: Long,
val libraryId: Long?,
val title: String, val title: String,
val lastChapterRead: Double,
val totalChapters: Int, val totalChapters: Int,
val trackingUrl: String, val trackingUrl: String,
val coverUrl: String, val coverUrl: String,
@@ -111,12 +120,19 @@ class TrackSearchType(
val publishingStatus: String, val publishingStatus: String,
val publishingType: String, val publishingType: String,
val startDate: String, val startDate: String,
val status: Int,
val score: Double,
val startedReadingDate: Long,
val finishedReadingDate: Long,
val private: Boolean,
) { ) {
constructor(row: ResultRow) : this( constructor(row: ResultRow) : this(
row[TrackSearchTable.id].value, row[TrackSearchTable.id].value,
row[TrackSearchTable.trackerId], row[TrackSearchTable.trackerId],
row[TrackSearchTable.remoteId], row[TrackSearchTable.remoteId],
row[TrackSearchTable.libraryId],
row[TrackSearchTable.title], row[TrackSearchTable.title],
row[TrackSearchTable.lastChapterRead],
row[TrackSearchTable.totalChapters], row[TrackSearchTable.totalChapters],
row[TrackSearchTable.trackingUrl], row[TrackSearchTable.trackingUrl],
row[TrackSearchTable.coverUrl], row[TrackSearchTable.coverUrl],
@@ -124,10 +140,18 @@ class TrackSearchType(
row[TrackSearchTable.publishingStatus], row[TrackSearchTable.publishingStatus],
row[TrackSearchTable.publishingType], row[TrackSearchTable.publishingType],
row[TrackSearchTable.startDate], row[TrackSearchTable.startDate],
row[TrackSearchTable.status],
row[TrackSearchTable.score],
row[TrackSearchTable.startedReadingDate],
row[TrackSearchTable.finishedReadingDate],
row[TrackSearchTable.private],
) )
fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackerType> = fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackerType> =
dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("TrackerDataLoader", trackerId) dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("TrackerDataLoader", trackerId)
fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<String> =
dataFetchingEnvironment.getValueFromDataLoader<Int, String>("DisplayScoreForTrackSearchDataLoader", id)
} }
data class TrackRecordNodeList( data class TrackRecordNodeList(

View File

@@ -114,15 +114,16 @@ object TrackController {
queryParam<Int>("mangaId"), queryParam<Int>("mangaId"),
queryParam<Int>("trackerId"), queryParam<Int>("trackerId"),
queryParam<String>("remoteId"), queryParam<String>("remoteId"),
queryParam<Boolean>("private"),
documentWith = { documentWith = {
withOperation { withOperation {
summary("Track Record Bind") summary("Track Record Bind")
description("Bind a Track Record to a Manga") description("Bind a Track Record to a Manga")
} }
}, },
behaviorOf = { ctx, mangaId, trackerId, remoteId -> behaviorOf = { ctx, mangaId, trackerId, remoteId, private ->
ctx.future { ctx.future {
future { Track.bind(mangaId, trackerId, remoteId.toLong()) } future { Track.bind(mangaId, trackerId, remoteId.toLong(), private) }
.thenApply { ctx.status(HttpStatus.OK) } .thenApply { ctx.status(HttpStatus.OK) }
} }
}, },

View File

@@ -473,14 +473,14 @@ object ProtoBackupImport : ProtoBackupBase() {
Tracker Tracker
.getTrackRecordsByMangaId(mangaId) .getTrackRecordsByMangaId(mangaId)
.mapNotNull { it.record?.toTrack() } .mapNotNull { it.record?.toTrack() }
.associateBy { it.sync_id } .associateBy { it.tracker_id }
val (existingTracks, newTracks) = val (existingTracks, newTracks) =
tracks tracks
.mapNotNull { backupTrack -> .mapNotNull { backupTrack ->
val track = backupTrack.toTrack(mangaId) val track = backupTrack.toTrack(mangaId)
val isUnsupportedTracker = TrackerManager.getTracker(track.sync_id) == null val isUnsupportedTracker = TrackerManager.getTracker(track.tracker_id) == null
if (isUnsupportedTracker) { if (isUnsupportedTracker) {
return@mapNotNull null return@mapNotNull null
} }
@@ -495,7 +495,7 @@ object ProtoBackupImport : ProtoBackupBase() {
} }
dbTrack.also { dbTrack.also {
it.media_id = track.media_id it.remote_id = track.remote_id
it.library_id = track.library_id it.library_id = track.library_id
it.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read) it.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
} }

View File

@@ -26,6 +26,7 @@ data class BackupTracking(
@ProtoNumber(10) var startedReadingDate: Long = 0, @ProtoNumber(10) var startedReadingDate: Long = 0,
// finishedReadingDate is called endReadTime in 1.x // finishedReadingDate is called endReadTime in 1.x
@ProtoNumber(11) var finishedReadingDate: Long = 0, @ProtoNumber(11) var finishedReadingDate: Long = 0,
@ProtoNumber(12) var private: Boolean = false,
@ProtoNumber(100) var mediaId: Long = 0, @ProtoNumber(100) var mediaId: Long = 0,
) { ) {
fun getTrackingImpl(): TrackImpl = fun getTrackingImpl(): TrackImpl =

View File

@@ -16,8 +16,8 @@ import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.selectAll import org.jetbrains.exposed.sql.selectAll
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update import org.jsoup.Jsoup
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager 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.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack 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.lastChapterRead
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.libraryId import suwayomi.tachidesk.manga.model.table.TrackRecordTable.libraryId
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.mangaId 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.remoteId
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.score import suwayomi.tachidesk.manga.model.table.TrackRecordTable.score
@@ -100,7 +101,7 @@ object Track {
if (record != null) { if (record != null) {
val track = val track =
Track.create(it.id).also { t -> Track.create(it.id).also { t ->
t.score = record.score.toFloat() t.score = record.score
} }
record.scoreString = it.displayScore(track) record.scoreString = it.displayScore(track)
} }
@@ -124,7 +125,9 @@ object Track {
id = it[TrackSearchTable.id].value, id = it[TrackSearchTable.id].value,
trackerId = it[TrackSearchTable.trackerId], trackerId = it[TrackSearchTable.trackerId],
remoteId = it[TrackSearchTable.remoteId], remoteId = it[TrackSearchTable.remoteId],
libraryId = it[TrackSearchTable.libraryId],
title = it[TrackSearchTable.title], title = it[TrackSearchTable.title],
lastChapterRead = it[TrackSearchTable.lastChapterRead],
totalChapters = it[TrackSearchTable.totalChapters], totalChapters = it[TrackSearchTable.totalChapters],
trackingUrl = it[TrackSearchTable.trackingUrl], trackingUrl = it[TrackSearchTable.trackingUrl],
coverUrl = it[TrackSearchTable.coverUrl], coverUrl = it[TrackSearchTable.coverUrl],
@@ -132,6 +135,12 @@ object Track {
publishingStatus = it[TrackSearchTable.publishingStatus], publishingStatus = it[TrackSearchTable.publishingStatus],
publishingType = it[TrackSearchTable.publishingType], publishingType = it[TrackSearchTable.publishingType],
startDate = it[TrackSearchTable.startDate], 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 = private fun ResultRow.toTrackFromSearch(mangaId: Int): Track =
Track.create(this[TrackSearchTable.trackerId]).also { Track.create(this[TrackSearchTable.trackerId]).also {
it.manga_id = mangaId it.manga_id = mangaId
it.media_id = this[TrackSearchTable.remoteId] it.remote_id = this[TrackSearchTable.remoteId]
it.title = this[TrackSearchTable.title] it.title = this[TrackSearchTable.title]
it.total_chapters = this[TrackSearchTable.totalChapters] it.total_chapters = this[TrackSearchTable.totalChapters]
it.tracking_url = this[TrackSearchTable.trackingUrl] it.tracking_url = this[TrackSearchTable.trackingUrl]
@@ -149,6 +158,7 @@ object Track {
mangaId: Int, mangaId: Int,
trackerId: Int, trackerId: Int,
remoteId: Long, remoteId: Long,
private: Boolean,
) { ) {
val track = val track =
transaction { transaction {
@@ -167,7 +177,8 @@ object Track {
}.first() }.first()
.toTrack() .toTrack()
.apply { .apply {
manga_id = mangaId this.manga_id = mangaId
this.private = private
} }
} }
val tracker = TrackerManager.getTracker(trackerId)!! val tracker = TrackerManager.getTracker(trackerId)!!
@@ -234,7 +245,7 @@ object Track {
val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId]) val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId])
if (deleteRemoteTrack == true && tracker is DeletableTrackService) { if (deleteRemoteTrack == true && tracker is DeletableTracker) {
tracker.delete(recordDb.toTrack()) tracker.delete(recordDb.toTrack())
} }
@@ -278,8 +289,7 @@ object Track {
} }
if (input.scoreString != null) { if (input.scoreString != null) {
val score = tracker.indexToScore(tracker.getScoreList().indexOf(input.scoreString)) val score = tracker.indexToScore(tracker.getScoreList().indexOf(input.scoreString))
// conversion issues between Float <-> Double so convert to string before double recordDb[TrackRecordTable.score] = score
recordDb[TrackRecordTable.score] = score.toString().toDouble()
} }
if (input.startDate != null) { if (input.startDate != null) {
recordDb[TrackRecordTable.startDate] = input.startDate recordDb[TrackRecordTable.startDate] = input.startDate
@@ -287,6 +297,9 @@ object Track {
if (input.finishDate != null) { if (input.finishDate != null) {
recordDb[TrackRecordTable.finishDate] = input.finishDate recordDb[TrackRecordTable.finishDate] = input.finishDate
} }
if (input.private != null) {
recordDb[TrackRecordTable.private] = input.private
}
val track = recordDb.toTrack() val track = recordDb.toTrack()
tracker.update(track) tracker.update(track)
@@ -384,7 +397,7 @@ object Track {
log.debug { "remoteLastReadChapter= $lastChapterRead" } log.debug { "remoteLastReadChapter= $lastChapterRead" }
if (chapterNumber > lastChapterRead) { if (chapterNumber > lastChapterRead) {
track.last_chapter_read = chapterNumber.toFloat() track.last_chapter_read = chapterNumber
tracker.update(track, true) tracker.update(track, true)
upsertTrackRecord(track) upsertTrackRecord(track)
} }
@@ -397,7 +410,7 @@ object Track {
.selectAll() .selectAll()
.where { .where {
(TrackRecordTable.mangaId eq track.manga_id) and (TrackRecordTable.mangaId eq track.manga_id) and
(TrackRecordTable.trackerId eq track.sync_id) (TrackRecordTable.trackerId eq track.tracker_id)
}.singleOrNull() }.singleOrNull()
if (existingRecord != null) { if (existingRecord != null) {
@@ -416,16 +429,17 @@ object Track {
BatchUpdateStatement(TrackRecordTable).apply { BatchUpdateStatement(TrackRecordTable).apply {
tracks.forEach { tracks.forEach {
addBatch(EntityID(it.id!!, TrackRecordTable)) addBatch(EntityID(it.id!!, TrackRecordTable))
this[remoteId] = it.media_id this[remoteId] = it.remote_id
this[libraryId] = it.library_id this[libraryId] = it.library_id
this[title] = it.title this[title] = it.title
this[lastChapterRead] = it.last_chapter_read.toDouble() this[lastChapterRead] = it.last_chapter_read
this[totalChapters] = it.total_chapters this[totalChapters] = it.total_chapters
this[status] = it.status this[status] = it.status
this[score] = it.score.toDouble() this[score] = it.score
this[remoteUrl] = it.tracking_url this[remoteUrl] = it.tracking_url
this[startDate] = it.started_reading_date this[startDate] = it.started_reading_date
this[finishDate] = it.finished_reading_date this[finishDate] = it.finished_reading_date
this[private] = it.private
} }
execute(this@transaction) execute(this@transaction)
} }
@@ -439,17 +453,18 @@ object Track {
TrackRecordTable TrackRecordTable
.batchInsert(tracks) { .batchInsert(tracks) {
this[mangaId] = it.manga_id this[mangaId] = it.manga_id
this[trackerId] = it.sync_id this[trackerId] = it.tracker_id
this[remoteId] = it.media_id this[remoteId] = it.remote_id
this[libraryId] = it.library_id this[libraryId] = it.library_id
this[title] = it.title this[title] = it.title
this[lastChapterRead] = it.last_chapter_read.toDouble() this[lastChapterRead] = it.last_chapter_read
this[totalChapters] = it.total_chapters this[totalChapters] = it.total_chapters
this[status] = it.status this[status] = it.status
this[score] = it.score.toDouble() this[score] = it.score
this[remoteUrl] = it.tracking_url this[remoteUrl] = it.tracking_url
this[startDate] = it.started_reading_date this[startDate] = it.started_reading_date
this[finishDate] = it.finished_reading_date this[finishDate] = it.finished_reading_date
this[private] = it.private
}.map { it[TrackRecordTable.id].value } }.map { it[TrackRecordTable.id].value }
} }
@@ -481,5 +496,8 @@ object Track {
val startDate: Long? = null, val startDate: Long? = null,
val finishDate: Long? = null, val finishDate: Long? = null,
val unbind: Boolean? = null, val unbind: Boolean? = null,
val private: Boolean? = null,
) )
fun String.htmlDecode(): String = Jsoup.parse(this).wholeText()
} }

View File

@@ -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 * For track services api that support deleting a manga entry for a user's list
*/ */
interface DeletableTrackService { interface DeletableTracker {
suspend fun delete(track: Track) suspend fun delete(track: Track)
} }

View File

@@ -20,7 +20,7 @@ abstract class Tracker(
// Application and remote support for reading dates // Application and remote support for reading dates
open val supportsReadingDates: Boolean = false open val supportsReadingDates: Boolean = false
abstract val supportsTrackDeletion: Boolean open val supportsPrivateTracking: Boolean = false
override fun toString() = "$name ($id) (isLoggedIn= $isLoggedIn, isAuthExpired= ${getIfAuthExpired()})" override fun toString() = "$name ($id) (isLoggedIn= $isLoggedIn, isAuthExpired= ${getIfAuthExpired()})"
@@ -38,7 +38,7 @@ abstract class Tracker(
abstract fun getScoreList(): List<String> abstract fun getScoreList(): List<String>
open fun indexToScore(index: Int): Float = index.toFloat() open fun indexToScore(index: Int): Double = index.toDouble()
abstract fun displayScore(track: Track): String abstract fun displayScore(track: Track): String

View File

@@ -27,7 +27,6 @@ object TrackerPreferences {
username: String, username: String,
password: String, password: String,
) { ) {
logger.debug { "setTrackCredentials: id=${sync.id} username=$username" }
preferenceStore preferenceStore
.edit() .edit()
.putString(trackUsername(sync.id), username) .putString(trackUsername(sync.id), username)
@@ -42,7 +41,6 @@ object TrackerPreferences {
sync: Tracker, sync: Tracker,
token: String?, token: String?,
) { ) {
logger.debug { "setTrackToken: id=${sync.id} token=$token" }
if (token == null) { if (token == null) {
preferenceStore preferenceStore
.edit() .edit()

View File

@@ -2,10 +2,10 @@ package suwayomi.tachidesk.manga.impl.track.tracker.anilist
import android.annotation.StringRes import android.annotation.StringRes
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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.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.extractToken
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track 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.model.TrackSearch
@@ -15,7 +15,7 @@ import java.io.IOException
class Anilist( class Anilist(
id: Int, id: Int,
) : Tracker(id, "AniList"), ) : Tracker(id, "AniList"),
DeletableTrackService { DeletableTracker {
companion object { companion object {
const val READING = 1 const val READING = 1
const val COMPLETED = 2 const val COMPLETED = 2
@@ -31,8 +31,6 @@ class Anilist(
const val POINT_3 = "POINT_3" const val POINT_3 = "POINT_3"
} }
override val supportsTrackDeletion: Boolean = true
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val interceptor by lazy { AnilistInterceptor(this) } private val interceptor by lazy { AnilistInterceptor(this) }
@@ -41,6 +39,8 @@ class Anilist(
override val supportsReadingDates: Boolean = true override val supportsReadingDates: Boolean = true
override val supportsPrivateTracking: Boolean = true
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
override fun getLogo(): String = "/static/tracker/anilist.png" override fun getLogo(): String = "/static/tracker/anilist.png"
@@ -80,26 +80,26 @@ class Anilist(
else -> throw Exception("Unknown score type") else -> throw Exception("Unknown score type")
} }
override fun indexToScore(index: Int): Float = override fun indexToScore(index: Int): Double =
when (trackPreferences.getScoreType(this)) { when (trackPreferences.getScoreType(this)) {
// 10 point // 10 point
POINT_10 -> index * 10f POINT_10 -> index * 10.0
// 100 point // 100 point
POINT_100 -> index.toFloat() POINT_100 -> index.toDouble()
// 5 stars // 5 stars
POINT_5 -> POINT_5 ->
when (index) { when (index) {
0 -> 0f 0 -> 0.0
else -> index * 20f - 10f else -> index * 20.0 - 10.0
} }
// Smiley // Smiley
POINT_3 -> POINT_3 ->
when (index) { when (index) {
0 -> 0f 0 -> 0.0
else -> index * 25f + 10f else -> index * 25.0 + 10.0
} }
// 10 point decimal // 10 point decimal
POINT_10_DECIMAL -> index.toFloat() POINT_10_DECIMAL -> index.toDouble()
else -> throw Exception("Unknown score type") else -> throw Exception("Unknown score type")
} }
@@ -108,17 +108,17 @@ class Anilist(
return when (val type = trackPreferences.getScoreType(this)) { return when (val type = trackPreferences.getScoreType(this)) {
POINT_5 -> POINT_5 ->
when (score) { when (score) {
0f -> "0 ★" 0.0 -> "0 ★"
else -> "${((score + 10) / 20).toInt()}" else -> "${((score + 10) / 20).toInt()}"
} }
POINT_3 -> POINT_3 ->
when { when {
score == 0f -> "0" score == 0.0 -> "0"
score <= 35 -> "😦" score <= 35 -> "😦"
score <= 60 -> "😐" score <= 60 -> "😐"
else -> "😊" else -> "😊"
} }
else -> track.toAnilistScore(type) else -> track.toApiScore(type)
} }
} }
@@ -143,7 +143,7 @@ class Anilist(
track.finished_reading_date = System.currentTimeMillis() track.finished_reading_date = System.currentTimeMillis()
} else if (track.status != REREADING) { } else if (track.status != REREADING) {
track.status = READING track.status = READING
if (track.last_chapter_read == 1F) { if (track.last_chapter_read == 1.0) {
track.started_reading_date = System.currentTimeMillis() track.started_reading_date = System.currentTimeMillis()
} }
} }
@@ -168,19 +168,19 @@ class Anilist(
): Track { ): Track {
val remoteTrack = api.findLibManga(track, getUsername().toInt()) val remoteTrack = api.findLibManga(track, getUsername().toInt())
return if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
track.library_id = remoteTrack.library_id track.library_id = remoteTrack.library_id
if (track.status != COMPLETED) { if (track.status != COMPLETED) {
val isRereading = track.status == REREADING 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) update(track)
} else { } else {
// Set default fields if it's not found in the list // Set default fields if it's not found in the list
track.status = if (hasReadChapters) READING else PLAN_TO_READ track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0F track.score = 0.0
add(track) add(track)
} }
} }
@@ -209,12 +209,11 @@ class Anilist(
private suspend fun login(token: String) { private suspend fun login(token: String) {
try { try {
logger.debug { "login $token" }
val oauth = api.createOAuth(token) val oauth = api.createOAuth(token)
interceptor.setAuth(oauth) interceptor.setAuth(oauth)
val (username, scoreType) = api.getCurrentUser() val (username, scoreType) = api.getCurrentUser()
trackPreferences.setScoreType(this, scoreType) trackPreferences.setScoreType(this, scoreType)
saveCredentials(username.toString(), oauth.access_token) saveCredentials(username.toString(), oauth.accessToken)
} catch (e: Throwable) { } catch (e: Throwable) {
logger.error(e) { "oauth err" } logger.error(e) { "oauth err" }
logout() logout()
@@ -228,13 +227,13 @@ class Anilist(
interceptor.setAuth(null) interceptor.setAuth(null)
} }
fun saveOAuth(oAuth: OAuth?) { fun saveOAuth(oAuth: ALOAuth?) {
trackPreferences.setTrackToken(this, json.encodeToString(oAuth)) trackPreferences.setTrackToken(this, json.encodeToString(oAuth))
} }
fun loadOAuth(): OAuth? = fun loadOAuth(): ALOAuth? =
try { try {
json.decodeFromString<OAuth>(trackPreferences.getTrackToken(this)!!) json.decodeFromString<ALOAuth>(trackPreferences.getTrackToken(this)!!)
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "loadOAuth err" } logger.error(e) { "loadOAuth err" }
null null

View File

@@ -12,22 +12,21 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject 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.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody 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.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.util.Calendar import java.time.Instant
import kotlin.time.Duration.Companion.days import java.time.ZoneId
import java.time.ZonedDateTime
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
class AnilistApi( class AnilistApi(
@@ -47,8 +46,8 @@ class AnilistApi(
withIOContext { withIOContext {
val query = val query =
""" """
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) { |mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean) {
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) { |SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private) {
| id | id
| status | status
|} |}
@@ -59,9 +58,10 @@ class AnilistApi(
buildJsonObject { buildJsonObject {
put("query", query) put("query", query)
putJsonObject("variables") { putJsonObject("variables") {
put("mangaId", track.media_id) put("mangaId", track.remote_id)
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("status", track.toAnilistStatus()) put("status", track.toApiStatus())
put("private", track.private)
} }
} }
with(json) { with(json) {
@@ -72,13 +72,9 @@ class AnilistApi(
body = payload.toString().toRequestBody(jsonMime), body = payload.toString().toRequestBody(jsonMime),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<ALAddMangaResult>()
.let { .let {
track.library_id = track.library_id = it.data.entry.id
it["data"]!!
.jsonObject["SaveMediaListEntry"]!!
.jsonObject["id"]!!
.jsonPrimitive.long
track track
} }
} }
@@ -89,11 +85,11 @@ class AnilistApi(
val query = val query =
""" """
|mutation UpdateManga( |mutation UpdateManga(
|${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, |${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean,
|${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput |${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput
|) { |) {
|SaveMediaListEntry( |SaveMediaListEntry(
|id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, |id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private,
|scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt |scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt
|) { |) {
|id |id
@@ -109,10 +105,11 @@ class AnilistApi(
putJsonObject("variables") { putJsonObject("variables") {
put("listId", track.library_id) put("listId", track.library_id)
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("status", track.toAnilistStatus()) put("status", track.toApiStatus())
put("score", track.score.toInt()) put("score", track.score.toInt())
put("startedAt", createDate(track.started_reading_date)) put("startedAt", createDate(track.started_reading_date))
put("completedAt", createDate(track.finished_reading_date)) put("completedAt", createDate(track.finished_reading_date))
put("private", track.private)
} }
} }
authClient authClient
@@ -121,7 +118,7 @@ class AnilistApi(
track track
} }
suspend fun deleteLibManga(track: Track) = suspend fun deleteLibManga(track: Track) {
withIOContext { withIOContext {
val query = val query =
""" """
@@ -143,6 +140,7 @@ class AnilistApi(
.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime))) .newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess() .awaitSuccess()
} }
}
suspend fun search(search: String): List<TrackSearch> = suspend fun search(search: String): List<TrackSearch> =
withIOContext { withIOContext {
@@ -152,6 +150,19 @@ class AnilistApi(
|Page (perPage: 50) { |Page (perPage: 50) {
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) { |media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|id |id
|staff {
|edges {
|role
|id
|node {
|name {
|full
|userPreferred
|native
|}
|}
|}
|}
|title { |title {
|userPreferred |userPreferred
|} |}
@@ -167,6 +178,7 @@ class AnilistApi(
|month |month
|day |day
|} |}
|averageScore
|} |}
|} |}
|} |}
@@ -187,14 +199,9 @@ class AnilistApi(
body = payload.toString().toRequestBody(jsonMime), body = payload.toString().toRequestBody(jsonMime),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<ALSearchResult>()
.let { response -> .data.page.media
val data = response["data"]!!.jsonObject .map { it.toALManga().toTrack() }
val page = data["Page"]!!.jsonObject
val media = page["media"]!!.jsonArray
val entries = media.map { jsonToALManga(it.jsonObject) }
entries.map { it.toTrack() }
}
} }
} }
@@ -212,6 +219,7 @@ class AnilistApi(
|status |status
|scoreRaw: score(format: POINT_100) |scoreRaw: score(format: POINT_100)
|progress |progress
|private
|startedAt { |startedAt {
|year |year
|month |month
@@ -239,6 +247,19 @@ class AnilistApi(
|month |month
|day |day
|} |}
|staff {
|edges {
|role
|id
|node {
|name {
|full
|userPreferred
|native
|}
|}
|}
|}
|} |}
|} |}
|} |}
@@ -250,7 +271,7 @@ class AnilistApi(
put("query", query) put("query", query)
putJsonObject("variables") { putJsonObject("variables") {
put("id", userid) put("id", userid)
put("manga_id", track.media_id) put("manga_id", track.remote_id)
} }
} }
with(json) { with(json) {
@@ -261,24 +282,20 @@ class AnilistApi(
body = payload.toString().toRequestBody(jsonMime), body = payload.toString().toRequestBody(jsonMime),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<ALUserListMangaQueryResult>()
.let { response -> .data.page.mediaList
val data = response["data"]!!.jsonObject .map { it.toALUserManga() }
val page = data["Page"]!!.jsonObject .firstOrNull()
val media = page["mediaList"]!!.jsonArray ?.toTrack()
val entries = media.map { jsonToALUserManga(it.jsonObject) }
entries.firstOrNull()?.toTrack()
}
} }
} }
suspend fun getLibManga( suspend fun getLibManga(
track: Track, track: Track,
userid: Int, userId: Int,
): Track = findLibManga(track, userid) ?: throw Exception("Could not find manga") ): Track = findLibManga(track, userId) ?: throw Exception("Could not find manga")
fun createOAuth(token: String): OAuth = fun createOAuth(token: String): ALOAuth = ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
OAuth(token, "Bearer", System.currentTimeMillis() + 365.days.inWholeMilliseconds, 365.days.inWholeMilliseconds)
suspend fun getCurrentUser(): Pair<Int, String> = suspend fun getCurrentUser(): Pair<Int, String> =
withIOContext { withIOContext {
@@ -306,57 +323,14 @@ class AnilistApi(
body = payload.toString().toRequestBody(jsonMime), body = payload.toString().toRequestBody(jsonMime),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<ALCurrentUserResult>()
.let { .let {
val data = it["data"]!!.jsonObject val viewer = it.data.viewer
val viewer = data["Viewer"]!!.jsonObject Pair(viewer.id, viewer.mediaListOptions.scoreFormat)
Pair(
viewer["id"]!!.jsonPrimitive.int,
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content,
)
} }
} }
} }
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 { private fun createDate(dateValue: Long): JsonObject {
if (dateValue == 0L) { if (dateValue == 0L) {
return buildJsonObject { return buildJsonObject {
@@ -366,12 +340,11 @@ class AnilistApi(
} }
} }
val calendar = Calendar.getInstance() val dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(dateValue), ZoneId.systemDefault())
calendar.timeInMillis = dateValue
return buildJsonObject { return buildJsonObject {
put("year", calendar.get(Calendar.YEAR)) put("year", dateTime.year)
put("month", calendar.get(Calendar.MONTH) + 1) put("month", dateTime.monthValue)
put("day", calendar.get(Calendar.DAY_OF_MONTH)) put("day", dateTime.dayOfMonth)
} }
} }

View File

@@ -3,10 +3,13 @@ package suwayomi.tachidesk.manga.impl.track.tracker.anilist
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired 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 import java.io.IOException
class AnilistInterceptor( class AnilistInterceptor(
private val anilist: Anilist, val anilist: Anilist,
) : Interceptor { ) : Interceptor {
/** /**
* OAuth object used for authenticated requests. * 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 * Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
* before its original expiration date. * before its original expiration date.
*/ */
private var oauth: OAuth? = null private var oauth: ALOAuth? = null
set(value) { set(value) {
field = value?.copy(expires = value.expires * 1000 - 60 * 1000) field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
} }
@@ -44,7 +47,8 @@ class AnilistInterceptor(
val authRequest = val authRequest =
originalRequest originalRequest
.newBuilder() .newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}") .addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})")
.build() .build()
return chain.proceed(authRequest) 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 * Called when the user authenticates with Anilist for the first time. Sets the refresh token
* and the oauth object. * and the oauth object.
*/ */
fun setAuth(oauth: OAuth?) { fun setAuth(oauth: ALOAuth?) {
this.oauth = oauth this.oauth = oauth
anilist.saveOAuth(oauth) anilist.saveOAuth(oauth)
} }

View File

@@ -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")
}

View File

@@ -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")
}

View File

@@ -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,
)

View File

@@ -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
}
}

View File

@@ -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")
}
}

View File

@@ -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

View File

@@ -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<ALSearchItem>,
)

View File

@@ -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<ALEdge>,
)
@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
}

View File

@@ -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,
)

View File

@@ -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<ALUserListItem>,
)
@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,
)
}

View File

@@ -3,6 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import android.annotation.StringRes import android.annotation.StringRes
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker 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.extractToken
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track 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.model.TrackSearch
@@ -24,14 +25,14 @@ class Bangumi(
.map(Int::toString) .map(Int::toString)
} }
override val supportsTrackDeletion: Boolean = false
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val interceptor by lazy { BangumiInterceptor(this) } private val interceptor by lazy { BangumiInterceptor(this) }
private val api by lazy { BangumiApi(id, client, interceptor) } private val api by lazy { BangumiApi(id, client, interceptor) }
override val supportsPrivateTracking: Boolean = true
override fun getScoreList(): List<String> = SCORE_LIST override fun getScoreList(): List<String> = SCORE_LIST
override fun displayScore(track: Track): String = track.score.toInt().toString() override fun displayScore(track: Track): String = track.score.toInt().toString()
@@ -61,7 +62,7 @@ class Bangumi(
): Track { ): Track {
val statusTrack = api.statusLibManga(track, getUsername()) val statusTrack = api.statusLibManga(track, getUsername())
return if (statusTrack != null) { return if (statusTrack != null) {
track.copyPersonalFrom(statusTrack) track.copyPersonalFrom(statusTrack, copyRemotePrivate = false)
track.library_id = statusTrack.library_id track.library_id = statusTrack.library_id
track.score = statusTrack.score track.score = statusTrack.score
track.last_chapter_read = statusTrack.last_chapter_read track.last_chapter_read = statusTrack.last_chapter_read
@@ -74,7 +75,7 @@ class Bangumi(
} else { } else {
// Set default fields if it's not found in the list // Set default fields if it's not found in the list
track.status = if (hasReadChapters) READING else PLAN_TO_READ track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0.0F track.score = 0.0
add(track) add(track)
} }
} }
@@ -151,13 +152,3 @@ class Bangumi(
interceptor.newAuth(null) 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")
}

View File

@@ -20,6 +20,10 @@ import okhttp3.Headers.Companion.headersOf
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody 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.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@@ -35,27 +39,33 @@ class BangumiApi(
suspend fun addLibManga(track: Track): Track = suspend fun addLibManga(track: Track): Track =
withIOContext { withIOContext {
val url = "$API_URL/v0/users/-/collections/${track.media_id}" val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
val body = val body =
buildJsonObject { buildJsonObject {
put("type", track.toApiStatus()) put("type", track.toApiStatus())
put("rate", track.score.toInt().coerceIn(0, 10)) put("rate", track.score.toInt().coerceIn(0, 10))
put("ep_status", track.last_chapter_read.toInt()) 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 // 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 track
} }
suspend fun updateLibManga(track: Track): Track = suspend fun updateLibManga(track: Track): Track =
withIOContext { withIOContext {
val url = "$API_URL/v0/users/-/collections/${track.media_id}" val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
val body = val body =
buildJsonObject { buildJsonObject {
put("type", track.toApiStatus()) put("type", track.toApiStatus())
put("rate", track.score.toInt().coerceIn(0, 10)) put("rate", track.score.toInt().coerceIn(0, 10))
put("ep_status", track.last_chapter_read.toInt()) put("ep_status", track.last_chapter_read.toInt())
}.toString().toRequestBody() put("private", track.private)
}.toString()
.toRequestBody()
val request = val request =
Request Request
@@ -65,7 +75,9 @@ class BangumiApi(
.headers(headersOf("Content-Type", APP_JSON)) .headers(headersOf("Content-Type", APP_JSON))
.build() .build()
// Returns with 204 No Content // Returns with 204 No Content
authClient.newCall(request).awaitSuccess() authClient
.newCall(request)
.awaitSuccess()
track track
} }
@@ -86,7 +98,8 @@ class BangumiApi(
add(1) // "Book" (书籍) type add(1) // "Book" (书籍) type
} }
} }
}.toString().toRequestBody() }.toString()
.toRequestBody()
with(json) { with(json) {
authClient authClient
.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))) .newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
@@ -104,7 +117,7 @@ class BangumiApi(
username: String, username: String,
): Track? = ): Track? =
withIOContext { 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) { with(json) {
try { try {
authClient authClient
@@ -113,8 +126,8 @@ class BangumiApi(
.parseAs<BGMCollectionResponse>() .parseAs<BGMCollectionResponse>()
.let { .let {
track.status = it.getStatus() track.status = it.getStatus()
track.last_chapter_read = it.epStatus?.toFloat() ?: 0.0F track.last_chapter_read = it.epStatus?.toDouble() ?: 0.0
track.score = it.rate?.toFloat() ?: 0.0F track.score = it.rate?.toDouble() ?: 0.0
track.total_chapters = it.subject?.eps ?: 0 track.total_chapters = it.subject?.eps ?: 0
track track
} }
@@ -140,7 +153,10 @@ class BangumiApi(
.add("redirect_uri", REDIRECT_URL) .add("redirect_uri", REDIRECT_URL)
.build() .build()
with(json) { with(json) {
client.newCall(POST(OAUTH_URL, body = body)).awaitSuccess().parseAs<BGMOAuth>() client
.newCall(POST(OAUTH_URL, body = body))
.awaitSuccess()
.parseAs<BGMOAuth>()
} }
} }

View File

@@ -3,6 +3,8 @@ package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response 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 suwayomi.tachidesk.server.generated.BuildConfig
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@@ -35,7 +37,7 @@ class BangumiInterceptor(
.newBuilder() .newBuilder()
.header( .header(
"User-Agent", "User-Agent",
"Suwayomi/Suwayomi-Server/v${BuildConfig.VERSION} (${BuildConfig.GITHUB})", "Suwayomi/Suwayomi-Server/${BuildConfig.VERSION} (${BuildConfig.GITHUB})",
).apply { ).apply {
addHeader("Authorization", "Bearer ${currAuth.accessToken}") addHeader("Authorization", "Bearer ${currAuth.accessToken}")
}.build() }.build()

View File

@@ -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<BGMSubject> = 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?,
)

View File

@@ -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")
}

View File

@@ -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?,
)

View File

@@ -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)

View File

@@ -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<BGMSubject> = 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?,
)

View File

@@ -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,
)

View File

@@ -1,9 +1,9 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
import android.annotation.StringRes import android.annotation.StringRes
import kotlinx.serialization.encodeToString import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
import kotlinx.serialization.json.Json 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.Tracker
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track 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.model.TrackSearch
@@ -13,7 +13,7 @@ import java.text.DecimalFormat
class Kitsu( class Kitsu(
id: Int, id: Int,
) : Tracker(id, "Kitsu"), ) : Tracker(id, "Kitsu"),
DeletableTrackService { DeletableTracker {
companion object { companion object {
const val READING = 1 const val READING = 1
const val COMPLETED = 2 const val COMPLETED = 2
@@ -22,10 +22,10 @@ class Kitsu(
const val PLAN_TO_READ = 5 const val PLAN_TO_READ = 5
} }
override val supportsTrackDeletion: Boolean = true
override val supportsReadingDates: Boolean = true override val supportsReadingDates: Boolean = true
override val supportsPrivateTracking: Boolean = true
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val interceptor by lazy { KitsuInterceptor(this) } private val interceptor by lazy { KitsuInterceptor(this) }
@@ -58,7 +58,7 @@ class Kitsu(
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) } 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 { override fun displayScore(track: Track): String {
val df = DecimalFormat("0.#") val df = DecimalFormat("0.#")
@@ -78,7 +78,7 @@ class Kitsu(
track.finished_reading_date = System.currentTimeMillis() track.finished_reading_date = System.currentTimeMillis()
} else { } else {
track.status = READING track.status = READING
if (track.last_chapter_read == 1.0f) { if (track.last_chapter_read == 1.0) {
track.started_reading_date = System.currentTimeMillis() track.started_reading_date = System.currentTimeMillis()
} }
} }
@@ -98,8 +98,8 @@ class Kitsu(
): Track { ): Track {
val remoteTrack = api.findLibManga(track, getUserId()) val remoteTrack = api.findLibManga(track, getUserId())
return if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
track.media_id = remoteTrack.media_id track.remote_id = remoteTrack.remote_id
if (track.status != COMPLETED) { if (track.status != COMPLETED) {
track.status = if (hasReadChapters) READING else track.status track.status = if (hasReadChapters) READING else track.status
@@ -108,7 +108,7 @@ class Kitsu(
update(track) update(track)
} else { } else {
track.status = if (hasReadChapters) READING else PLAN_TO_READ track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0.0f track.score = 0.0
add(track) add(track)
} }
} }
@@ -140,14 +140,14 @@ class Kitsu(
private fun getUserId(): String = getPassword() private fun getUserId(): String = getPassword()
// TODO: this seems to be called saveOAuth in other trackers // TODO: this seems to be called saveOAuth in other trackers
fun saveToken(oauth: OAuth?) { fun saveToken(oauth: KitsuOAuth?) {
trackPreferences.setTrackToken(this, json.encodeToString(oauth)) trackPreferences.setTrackToken(this, json.encodeToString(oauth))
} }
// TODO: this seems to be called loadOAuth in other trackers // TODO: this seems to be called loadOAuth in other trackers
fun restoreToken(): OAuth? = fun restoreToken(): KitsuOAuth? =
try { try {
json.decodeFromString<OAuth>(trackPreferences.getTrackToken(this)!!) json.decodeFromString<KitsuOAuth>(trackPreferences.getTrackToken(this)!!)
} catch (e: Exception) { } catch (e: Exception) {
null null
} }

View File

@@ -1,6 +1,7 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
import androidx.core.net.toUri 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.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST 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.network.parseAs
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject 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.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.FormBody import okhttp3.FormBody
@@ -24,6 +20,11 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody 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.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@@ -48,8 +49,9 @@ class KitsuApi(
putJsonObject("data") { putJsonObject("data") {
put("type", "libraryEntries") put("type", "libraryEntries")
putJsonObject("attributes") { putJsonObject("attributes") {
put("status", track.toKitsuStatus()) put("status", track.toApiStatus())
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("private", track.private)
} }
putJsonObject("relationships") { putJsonObject("relationships") {
putJsonObject("user") { putJsonObject("user") {
@@ -60,7 +62,7 @@ class KitsuApi(
} }
putJsonObject("media") { putJsonObject("media") {
putJsonObject("data") { putJsonObject("data") {
put("id", track.media_id) put("id", track.remote_id)
put("type", "manga") put("type", "manga")
} }
} }
@@ -73,20 +75,13 @@ class KitsuApi(
.newCall( .newCall(
POST( POST(
"${BASE_URL}library-entries", "${BASE_URL}library-entries",
headers = headers = headersOf("Content-Type", VND_API_JSON),
headersOf( body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE),
"Content-Type",
"application/vnd.api+json",
),
body =
data
.toString()
.toRequestBody("application/vnd.api+json".toMediaType()),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuAddMangaResult>()
.let { .let {
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long track.remote_id = it.data.id
track track
} }
} }
@@ -98,50 +93,39 @@ class KitsuApi(
buildJsonObject { buildJsonObject {
putJsonObject("data") { putJsonObject("data") {
put("type", "libraryEntries") put("type", "libraryEntries")
put("id", track.media_id) put("id", track.remote_id)
putJsonObject("attributes") { putJsonObject("attributes") {
put("status", track.toKitsuStatus()) put("status", track.toApiStatus())
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("ratingTwenty", track.toKitsuScore()) put("ratingTwenty", track.toApiScore())
put("startedAt", KitsuDateHelper.convert(track.started_reading_date)) put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date)) put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
put("private", track.private)
} }
} }
} }
with(json) {
authClient authClient
.newCall( .newCall(
Request Request
.Builder() .Builder()
.url("${BASE_URL}library-entries/${track.media_id}") .url("${BASE_URL}library-entries/${track.remote_id}")
.headers( .headers(
headersOf( headersOf("Content-Type", VND_API_JSON),
"Content-Type", ).patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE))
"application/vnd.api+json", .build(),
),
).patch(
data.toString().toRequestBody("application/vnd.api+json".toMediaType()),
).build(),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>()
.let {
track track
} }
}
}
suspend fun removeLibManga(track: Track) { suspend fun removeLibManga(track: Track) {
withIOContext { withIOContext {
authClient authClient
.newCall( .newCall(
DELETE( DELETE(
"${BASE_URL}library-entries/${track.media_id}", "${BASE_URL}library-entries/${track.remote_id}",
headers = headers = headersOf("Content-Type", VND_API_JSON),
headersOf(
"Content-Type",
"application/vnd.api+json",
),
), ),
).awaitSuccess() ).awaitSuccess()
} }
@@ -153,10 +137,9 @@ class KitsuApi(
authClient authClient
.newCall(GET(ALGOLIA_KEY_URL)) .newCall(GET(ALGOLIA_KEY_URL))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuSearchResult>()
.let { .let {
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content algoliaSearch(it.media.key, query)
algoliaSearch(key, query)
} }
} }
} }
@@ -186,16 +169,12 @@ class KitsuApi(
body = jsonObject.toString().toRequestBody(jsonMime), body = jsonObject.toString().toRequestBody(jsonMime),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuAlgoliaSearchResult>()
.let { .hits
it["hits"]!! .filter { it.subtype != "novel" }
.jsonArray
.map { KitsuSearchManga(it.jsonObject) }
.filter { it.subType != "novel" }
.map { it.toTrack() } .map { it.toTrack() }
} }
} }
}
suspend fun findLibManga( suspend fun findLibManga(
track: Track, track: Track,
@@ -206,19 +185,17 @@ class KitsuApi(
"${BASE_URL}library-entries" "${BASE_URL}library-entries"
.toUri() .toUri()
.buildUpon() .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") .appendQueryParameter("include", "manga")
.build() .build()
with(json) { with(json) {
authClient authClient
.newCall(GET(url.toString())) .newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuListSearchResult>()
.let { .let {
val data = it["data"]!!.jsonArray if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
if (data.size > 0) { it.firstToTrack()
val manga = it["included"]!!.jsonArray[0].jsonObject
KitsuLibManga(data[0].jsonObject, manga).toTrack()
} else { } else {
null null
} }
@@ -232,19 +209,17 @@ class KitsuApi(
"${BASE_URL}library-entries" "${BASE_URL}library-entries"
.toUri() .toUri()
.buildUpon() .buildUpon()
.encodedQuery("filter[id]=${track.media_id}") .encodedQuery("filter[id]=${track.remote_id}")
.appendQueryParameter("include", "manga") .appendQueryParameter("include", "manga")
.build() .build()
with(json) { with(json) {
authClient authClient
.newCall(GET(url.toString())) .newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuListSearchResult>()
.let { .let {
val data = it["data"]!!.jsonArray if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
if (data.size > 0) { it.firstToTrack()
val manga = it["included"]!!.jsonArray[0].jsonObject
KitsuLibManga(data[0].jsonObject, manga).toTrack()
} else { } else {
throw Exception("Could not find manga") throw Exception("Could not find manga")
} }
@@ -255,7 +230,7 @@ class KitsuApi(
suspend fun login( suspend fun login(
username: String, username: String,
password: String, password: String,
): OAuth = ): KitsuOAuth =
withIOContext { withIOContext {
val formBody: RequestBody = val formBody: RequestBody =
FormBody FormBody
@@ -286,13 +261,9 @@ class KitsuApi(
authClient authClient
.newCall(GET(url.toString())) .newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<KitsuCurrentUserResult>()
.let { .data[0]
it["data"]!! .id
.jsonArray[0]
.jsonObject["id"]!!
.jsonPrimitive.content
}
} }
} }
@@ -312,6 +283,9 @@ class KitsuApi(
"%5B%22synopsis%22%2C%22averageRating%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22" + "%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" "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 mangaUrl(remoteId: Long): String = BASE_MANGA_URL + remoteId
fun refreshTokenRequest(token: String) = fun refreshTokenRequest(token: String) =

View File

@@ -1,5 +1,7 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu 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 kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
@@ -14,14 +16,14 @@ class KitsuInterceptor(
/** /**
* OAuth object used for authenticated requests. * 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 { override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request() val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu") val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
val refreshToken = currAuth.refresh_token!! val refreshToken = currAuth.refreshToken!!
// Refresh access token if expired. // Refresh access token if expired.
if (currAuth.isExpired()) { if (currAuth.isExpired()) {
@@ -37,7 +39,7 @@ class KitsuInterceptor(
val authRequest = val authRequest =
originalRequest originalRequest
.newBuilder() .newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}") .addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})") .header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})")
.header("Accept", "application/vnd.api+json") .header("Accept", "application/vnd.api+json")
.header("Content-Type", "application/vnd.api+json") .header("Content-Type", "application/vnd.api+json")
@@ -46,7 +48,7 @@ class KitsuInterceptor(
return chain.proceed(authRequest) return chain.proceed(authRequest)
} }
fun newAuth(oauth: OAuth?) { fun newAuth(oauth: KitsuOAuth?) {
this.oauth = oauth this.oauth = oauth
kitsu.saveToken(oauth) kitsu.saveToken(oauth)
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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<KitsuListSearchItemData>,
val included: List<KitsuListSearchItemIncluded> = 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,
)

View File

@@ -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)

View File

@@ -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<KitsuAlgoliaSearchItem>,
)
@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))
} ?: ""
}
}

View File

@@ -0,0 +1,8 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuSearchItemCover(
val original: String?,
)

View File

@@ -0,0 +1,13 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuCurrentUserResult(
val data: List<KitsuUser>,
)
@Serializable
data class KitsuUser(
val id: String,
)

View File

@@ -1,9 +1,9 @@
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates 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.Tracker
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.ListItem import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUListItem
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Rating 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.copyTo
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.toTrackSearch import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.toTrackSearch
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
@@ -12,7 +12,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
class MangaUpdates( class MangaUpdates(
id: Int, id: Int,
) : Tracker(id, "MangaUpdates"), ) : Tracker(id, "MangaUpdates"),
DeletableTrackService { DeletableTracker {
companion object { companion object {
const val READING_LIST = 0 const val READING_LIST = 0
const val WISH_LIST = 1 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 interceptor by lazy { MangaUpdatesInterceptor(this) }
private val api by lazy { MangaUpdatesApi(interceptor, client) } private val api by lazy { MangaUpdatesApi(interceptor, client) }
@@ -62,7 +60,7 @@ class MangaUpdates(
override fun getScoreList(): List<String> = SCORE_LIST override fun getScoreList(): List<String> = 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() override fun displayScore(track: Track): String = track.score.toString()
@@ -88,8 +86,8 @@ class MangaUpdates(
try { try {
val (series, rating) = api.getSeriesListItem(track) val (series, rating) = api.getSeriesListItem(track)
track.copyFrom(series, rating) track.copyFrom(series, rating)
} catch (e: Exception) { } catch (_: Exception) {
track.score = 0f track.score = 0.0
api.addSeriesToList(track, hasReadChapters) api.addSeriesToList(track, hasReadChapters)
track track
} }
@@ -107,12 +105,12 @@ class MangaUpdates(
} }
private fun Track.copyFrom( private fun Track.copyFrom(
item: ListItem, item: MUListItem,
rating: Rating?, rating: MURating?,
): Track = ): Track =
apply { apply {
item.copyTo(this) item.copyTo(this)
score = rating?.rating ?: 0f score = rating?.rating ?: 0.0
} }
override suspend fun login( override suspend fun login(
@@ -124,5 +122,5 @@ class MangaUpdates(
interceptor.newAuth(authenticated.sessionToken) interceptor.newAuth(authenticated.sessionToken)
} }
fun restoreSession(): String? = trackPreferences.getTrackPassword(this) fun restoreSession(): String? = trackPreferences.getTrackPassword(this)?.ifBlank { null }
} }

View File

@@ -7,14 +7,10 @@ import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject 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.put
import kotlinx.serialization.json.putJsonObject import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
@@ -22,10 +18,12 @@ import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody 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.READING_LIST
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Companion.WISH_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.MUContext
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.ListItem import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUListItem
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Rating import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MULoginResponse
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Record 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 suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@@ -35,9 +33,6 @@ class MangaUpdatesApi(
) { ) {
private val json: Json by injectLazy() 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 { private val authClient by lazy {
client client
.newBuilder() .newBuilder()
@@ -45,13 +40,13 @@ class MangaUpdatesApi(
.build() .build()
} }
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> { suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> {
val listItem = val listItem =
with(json) { with(json) {
authClient authClient
.newCall(GET("$baseUrl/v1/lists/series/${track.media_id}")) .newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}"))
.awaitSuccess() .awaitSuccess()
.parseAs<ListItem>() .parseAs<MUListItem>()
} }
val rating = getSeriesRating(track) val rating = getSeriesRating(track)
@@ -68,7 +63,7 @@ class MangaUpdatesApi(
buildJsonArray { buildJsonArray {
addJsonObject { addJsonObject {
putJsonObject("series") { putJsonObject("series") {
put("id", track.media_id) put("id", track.remote_id)
} }
put("list_id", status) put("list_id", status)
} }
@@ -76,14 +71,14 @@ class MangaUpdatesApi(
authClient authClient
.newCall( .newCall(
POST( POST(
url = "$baseUrl/v1/lists/series", url = "$BASE_URL/v1/lists/series",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
).awaitSuccess() ).awaitSuccess()
.let { .let {
if (it.code == 200) { if (it.code == 200) {
track.status = status track.status = status
track.last_chapter_read = 1f track.last_chapter_read = 1.0
} }
} }
} }
@@ -93,7 +88,7 @@ class MangaUpdatesApi(
buildJsonArray { buildJsonArray {
addJsonObject { addJsonObject {
putJsonObject("series") { putJsonObject("series") {
put("id", track.media_id) put("id", track.remote_id)
} }
put("list_id", track.status) put("list_id", track.status)
putJsonObject("status") { putJsonObject("status") {
@@ -104,8 +99,8 @@ class MangaUpdatesApi(
authClient authClient
.newCall( .newCall(
POST( POST(
url = "$baseUrl/v1/lists/series/update", url = "$BASE_URL/v1/lists/series/update",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
).awaitSuccess() ).awaitSuccess()
@@ -115,31 +110,32 @@ class MangaUpdatesApi(
suspend fun deleteSeriesFromList(track: Track) { suspend fun deleteSeriesFromList(track: Track) {
val body = val body =
buildJsonArray { buildJsonArray {
add(track.media_id) add(track.remote_id)
} }
authClient authClient
.newCall( .newCall(
POST( POST(
url = "$baseUrl/v1/lists/series/delete", url = "$BASE_URL/v1/lists/series/delete",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
).awaitSuccess() ).awaitSuccess()
} }
private suspend fun getSeriesRating(track: Track): Rating? = private suspend fun getSeriesRating(track: Track): MURating? =
try { try {
with(json) { with(json) {
authClient authClient
.newCall(GET("$baseUrl/v1/series/${track.media_id}/rating")) .newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating"))
.awaitSuccess() .awaitSuccess()
.parseAs<Rating>() .parseAs<MURating>()
} }
} catch (e: Exception) { } catch (_: Exception) {
null null
} }
private suspend fun updateSeriesRating(track: Track) { private suspend fun updateSeriesRating(track: Track) {
if (track.score != 0f) { if (track.score < 0.0) return
if (track.score != 0.0) {
val body = val body =
buildJsonObject { buildJsonObject {
put("rating", track.score) put("rating", track.score)
@@ -147,21 +143,19 @@ class MangaUpdatesApi(
authClient authClient
.newCall( .newCall(
PUT( PUT(
url = "$baseUrl/v1/series/${track.media_id}/rating", url = "$BASE_URL/v1/series/${track.remote_id}/rating",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
).awaitSuccess() ).awaitSuccess()
} else { } else {
authClient authClient
.newCall( .newCall(
DELETE( DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"),
url = "$baseUrl/v1/series/${track.media_id}/rating",
),
).awaitSuccess() ).awaitSuccess()
} }
} }
suspend fun search(query: String): List<Record> { suspend fun search(query: String): List<MURecord> {
val body = val body =
buildJsonObject { buildJsonObject {
put("search", query) put("search", query)
@@ -173,27 +167,25 @@ class MangaUpdatesApi(
}, },
) )
} }
return with(json) { return with(json) {
client client
.newCall( .newCall(
POST( POST(
url = "$baseUrl/v1/series/search", url = "$BASE_URL/v1/series/search",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<MUSearchResult>()
.let { obj -> .results
obj["results"]?.jsonArray?.map { element -> .map { it.record }
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
}
}.orEmpty()
} }
} }
suspend fun authenticate( suspend fun authenticate(
username: String, username: String,
password: String, password: String,
): Context? { ): MUContext? {
val body = val body =
buildJsonObject { buildJsonObject {
put("username", username) put("username", username)
@@ -203,19 +195,18 @@ class MangaUpdatesApi(
client client
.newCall( .newCall(
PUT( PUT(
url = "$baseUrl/v1/account/login", url = "$BASE_URL/v1/account/login",
body = body.toString().toRequestBody(contentType), body = body.toString().toRequestBody(CONTENT_TYPE),
), ),
).awaitSuccess() ).awaitSuccess()
.parseAs<JsonObject>() .parseAs<MULoginResponse>()
.let { obj -> .context
try {
json.decodeFromJsonElement<Context>(obj["context"]!!)
} catch (e: Exception) {
// logcat(LogPriority.ERROR, e)
null
}
} }
} }
companion object {
private const val BASE_URL = "https://api.mangaupdates.com"
private val CONTENT_TYPE = "application/vnd.api+json".toMediaType()
} }
} }

View File

@@ -20,7 +20,7 @@ class MangaUpdatesInterceptor(
originalRequest originalRequest
.newBuilder() .newBuilder()
.addHeader("Authorization", "Bearer $token") .addHeader("Authorization", "Bearer $token")
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION}") .header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})")
.build() .build()
return chain.proceed(authRequest) return chain.proceed(authRequest)

View File

@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Context( data class MUContext(
@SerialName("session_token") @SerialName("session_token")
val sessionToken: String, val sessionToken: String,
val uid: Long, val uid: Long,

View File

@@ -3,8 +3,8 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Image( data class MUImage(
val url: Url? = null, val url: MUUrl? = null,
val height: Int? = null, val height: Int? = null,
val width: Int? = null, val width: Int? = null,
) )

View File

@@ -6,16 +6,16 @@ import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Com
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
@Serializable @Serializable
data class ListItem( data class MUListItem(
val series: Series? = null, val series: MUSeries? = null,
@SerialName("list_id") @SerialName("list_id")
val listId: Int? = null, val listId: Int? = null,
val status: Status? = null, val status: MUStatus? = null,
val priority: Int? = null, val priority: Int? = null,
) )
fun ListItem.copyTo(track: Track): Track = fun MUListItem.copyTo(track: Track): Track =
track.apply { track.apply {
this.status = listId ?: READING_LIST 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
} }

View File

@@ -0,0 +1,8 @@
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
import kotlinx.serialization.Serializable
@Serializable
data class MULoginResponse(
val context: MUContext,
)

View File

@@ -4,11 +4,11 @@ import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
@Serializable @Serializable
data class Rating( data class MURating(
val rating: Float? = null, val rating: Double? = null,
) )
fun Rating.copyTo(track: Track): Track = fun MURating.copyTo(track: Track): Track =
track.apply { track.apply {
this.score = rating ?: 0f this.score = rating ?: 0.0
} }

View File

@@ -2,17 +2,17 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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 import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
@Serializable @Serializable
data class Record( data class MURecord(
@SerialName("series_id") @SerialName("series_id")
val seriesId: Long? = null, val seriesId: Long? = null,
val title: String? = null, val title: String? = null,
val url: String? = null, val url: String? = null,
val description: String? = null, val description: String? = null,
val image: Image? = null, val image: MUImage? = null,
val type: String? = null, val type: String? = null,
val year: String? = null, val year: String? = null,
@SerialName("bayesian_rating") @SerialName("bayesian_rating")
@@ -23,11 +23,9 @@ data class Record(
val latestChapter: Int? = null, val latestChapter: Int? = null,
) )
private fun String.htmlDecode(): String = Jsoup.parse(this).wholeText() fun MURecord.toTrackSearch(id: Int): TrackSearch =
fun Record.toTrackSearch(id: Int): TrackSearch =
TrackSearch.create(id).apply { TrackSearch.create(id).apply {
media_id = this@toTrackSearch.seriesId ?: 0L remote_id = this@toTrackSearch.seriesId ?: 0L
title = this@toTrackSearch.title?.htmlDecode() ?: "" title = this@toTrackSearch.title?.htmlDecode() ?: ""
total_chapters = 0 total_chapters = 0
cover_url = this@toTrackSearch.image?.url?.original ?: "" cover_url = this@toTrackSearch.image?.url?.original ?: ""

View File

@@ -0,0 +1,13 @@
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
import kotlinx.serialization.Serializable
@Serializable
data class MUSearchResult(
val results: List<MUSearchResultItem>,
)
@Serializable
data class MUSearchResultItem(
val record: MURecord,
)

View File

@@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Series( data class MUSeries(
val id: Long? = null, val id: Long? = null,
val title: String? = null, val title: String? = null,
) )

View File

@@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Status( data class MUStatus(
val volume: Int? = null, val volume: Int? = null,
val chapter: Int? = null, val chapter: Int? = null,
) )

View File

@@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class Url( data class MUUrl(
val original: String? = null, val original: String? = null,
val thumb: String? = null, val thumb: String? = null,
) )

View File

@@ -9,19 +9,19 @@ interface Track : Serializable {
var manga_id: Int 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 library_id: Long?
var title: String var title: String
var last_chapter_read: Float var last_chapter_read: Double
var total_chapters: Int var total_chapters: Int
var score: Float var score: Double
var status: Int var status: Int
@@ -31,18 +31,24 @@ interface Track : Serializable {
var tracking_url: String 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 last_chapter_read = other.last_chapter_read
score = other.score score = other.score
status = other.status status = other.status
started_reading_date = other.started_reading_date started_reading_date = other.started_reading_date
finished_reading_date = other.finished_reading_date finished_reading_date = other.finished_reading_date
if (copyRemotePrivate) private = other.private
} }
companion object { companion object {
fun create(serviceId: Int): Track = fun create(serviceId: Int): Track =
TrackImpl().apply { TrackImpl().apply {
sync_id = serviceId tracker_id = serviceId
} }
} }
} }

View File

@@ -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
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.lastChapterRead import suwayomi.tachidesk.manga.model.table.TrackRecordTable.lastChapterRead
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl
import suwayomi.tachidesk.manga.model.table.TrackSearchTable
fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass = fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass =
TrackRecordDataClass( TrackRecordDataClass(
@@ -22,62 +23,89 @@ fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass =
remoteUrl = this[TrackRecordTable.remoteUrl], remoteUrl = this[TrackRecordTable.remoteUrl],
startDate = this[TrackRecordTable.startDate], startDate = this[TrackRecordTable.startDate],
finishDate = this[TrackRecordTable.finishDate], finishDate = this[TrackRecordTable.finishDate],
private = this[TrackRecordTable.private],
) )
fun ResultRow.toTrack(): Track = fun ResultRow.toTrack(): Track =
Track.create(this[TrackRecordTable.trackerId]).also { Track.create(this[TrackRecordTable.trackerId]).also {
it.id = this[TrackRecordTable.id].value it.id = this[TrackRecordTable.id].value
it.manga_id = this[TrackRecordTable.mangaId].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.library_id = this[TrackRecordTable.libraryId]
it.title = this[TrackRecordTable.title] 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.total_chapters = this[TrackRecordTable.totalChapters]
it.status = this[TrackRecordTable.status] it.status = this[TrackRecordTable.status]
it.score = this[TrackRecordTable.score].toFloat() it.score = this[TrackRecordTable.score]
it.tracking_url = this[TrackRecordTable.remoteUrl] it.tracking_url = this[TrackRecordTable.remoteUrl]
it.started_reading_date = this[TrackRecordTable.startDate] it.started_reading_date = this[TrackRecordTable.startDate]
it.finished_reading_date = this[TrackRecordTable.finishDate] it.finished_reading_date = this[TrackRecordTable.finishDate]
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 = fun BackupTracking.toTrack(mangaId: Int): Track =
Track.create(syncId).also { Track.create(syncId).also {
it.id = -1 it.id = -1
it.manga_id = mangaId it.manga_id = mangaId
it.media_id = mediaId it.remote_id = mediaId
it.library_id = libraryId it.library_id = libraryId
it.title = title it.title = title
it.last_chapter_read = lastChapterRead it.last_chapter_read = lastChapterRead.toDouble()
it.total_chapters = totalChapters it.total_chapters = totalChapters
it.status = status it.status = status
it.score = score it.score = score.toDouble()
it.tracking_url = trackingUrl it.tracking_url = trackingUrl
it.started_reading_date = startedReadingDate it.started_reading_date = startedReadingDate
it.finished_reading_date = finishedReadingDate it.finished_reading_date = finishedReadingDate
it.private = private
} }
fun TrackRecordDataClass.toTrack(): Track = fun TrackRecordDataClass.toTrack(): Track =
Track.create(trackerId).also { Track.create(trackerId).also {
it.id = id it.id = id
it.manga_id = mangaId it.manga_id = mangaId
it.media_id = remoteId it.remote_id = remoteId
it.library_id = libraryId it.library_id = libraryId
it.title = title it.title = title
it.last_chapter_read = lastChapterRead.toFloat() it.last_chapter_read = lastChapterRead
it.total_chapters = totalChapters it.total_chapters = totalChapters
it.status = status it.status = status
it.score = score.toFloat() it.score = score
it.tracking_url = remoteUrl it.tracking_url = remoteUrl
it.started_reading_date = startDate it.started_reading_date = startDate
it.finished_reading_date = finishDate it.finished_reading_date = finishDate
it.private = private
} }
fun Track.toTrackRecordDataClass(): TrackRecordDataClass = fun Track.toTrackRecordDataClass(): TrackRecordDataClass =
TrackRecordDataClass( TrackRecordDataClass(
id = id ?: -1, id = id ?: -1,
mangaId = manga_id, mangaId = manga_id,
trackerId = sync_id, trackerId = tracker_id,
remoteId = media_id, remoteId = remote_id,
libraryId = library_id, libraryId = library_id,
title = title, title = title,
lastChapterRead = last_chapter_read.toDouble(), lastChapterRead = last_chapter_read.toDouble(),
@@ -87,4 +115,5 @@ fun Track.toTrackRecordDataClass(): TrackRecordDataClass =
remoteUrl = tracking_url, remoteUrl = tracking_url,
startDate = started_reading_date, startDate = started_reading_date,
finishDate = finished_reading_date, finishDate = finished_reading_date,
private = private,
) )

View File

@@ -7,19 +7,19 @@ class TrackImpl : Track {
override var manga_id: Int = 0 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 var library_id: Long? = null
override lateinit var title: String 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 total_chapters: Int = 0
override var score: Float = 0f override var score: Double = 0.0
override var status: Int = 0 override var status: Int = 0
@@ -28,4 +28,6 @@ class TrackImpl : Track {
override var finished_reading_date: Long = 0 override var finished_reading_date: Long = 0
override var tracking_url: String = "" override var tracking_url: String = ""
override var private: Boolean = false
} }

View File

@@ -2,16 +2,38 @@
package suwayomi.tachidesk.manga.impl.track.tracker.model package suwayomi.tachidesk.manga.impl.track.tracker.model
class TrackSearch { class TrackSearch : Track {
var sync_id: Int = 0 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<String> = emptyList()
var artists: List<String> = emptyList()
var cover_url: String = "" var cover_url: String = ""
@@ -29,22 +51,24 @@ class TrackSearch {
other as TrackSearch other as TrackSearch
if (sync_id != other.sync_id) return false if (manga_id != other.manga_id) return false
if (media_id != other.media_id) return false if (tracker_id != other.tracker_id) return false
if (remote_id != other.remote_id) return false
return true return true
} }
override fun hashCode(): Int { override fun hashCode(): Int {
var result = sync_id.hashCode() var result = manga_id.hashCode()
result = 31 * result + media_id.hashCode() result = 31 * result + tracker_id.hashCode()
result = 31 * result + remote_id.hashCode()
return result return result
} }
companion object { companion object {
fun create(serviceId: Int): TrackSearch = fun create(serviceId: Int): TrackSearch =
TrackSearch().apply { TrackSearch().apply {
sync_id = serviceId tracker_id = serviceId
} }
} }
} }

View File

@@ -2,20 +2,20 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist
import android.annotation.StringRes import android.annotation.StringRes
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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.Tracker
import suwayomi.tachidesk.manga.impl.track.tracker.extractToken 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.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch 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 uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
class MyAnimeList( class MyAnimeList(
id: Int, id: Int,
) : Tracker(id, "MyAnimeList"), ) : Tracker(id, "MyAnimeList"),
DeletableTrackService { DeletableTracker {
companion object { companion object {
const val READING = 1 const val READING = 1
const val COMPLETED = 2 const val COMPLETED = 2
@@ -28,8 +28,6 @@ class MyAnimeList(
private const val SEARCH_LIST_PREFIX = "my:" private const val SEARCH_LIST_PREFIX = "my:"
} }
override val supportsTrackDeletion: Boolean = true
private val json: Json by injectLazy() private val json: Json by injectLazy()
private val interceptor by lazy { MyAnimeListInterceptor(this) } private val interceptor by lazy { MyAnimeListInterceptor(this) }
@@ -78,7 +76,7 @@ class MyAnimeList(
track.finished_reading_date = System.currentTimeMillis() track.finished_reading_date = System.currentTimeMillis()
} else if (track.status != REREADING) { } else if (track.status != REREADING) {
track.status = READING track.status = READING
if (track.last_chapter_read == 1F) { if (track.last_chapter_read == 1.0) {
track.started_reading_date = System.currentTimeMillis() track.started_reading_date = System.currentTimeMillis()
} }
} }
@@ -99,18 +97,18 @@ class MyAnimeList(
val remoteTrack = api.findListItem(track) val remoteTrack = api.findListItem(track)
return if (remoteTrack != null) { return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack) track.copyPersonalFrom(remoteTrack)
track.media_id = remoteTrack.media_id track.remote_id = remoteTrack.remote_id
if (track.status != COMPLETED) { if (track.status != COMPLETED) {
val isRereading = track.status == REREADING 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) update(track)
} else { } else {
// Set default fields if it's not found in the list // Set default fields if it's not found in the list
track.status = if (hasReadChapters) READING else PLAN_TO_READ track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0F track.score = 0.0
add(track) add(track)
} }
} }
@@ -147,11 +145,10 @@ class MyAnimeList(
suspend fun login(authCode: String) { suspend fun login(authCode: String) {
try { try {
logger.debug { "login $authCode" }
val oauth = api.getAccessToken(authCode) val oauth = api.getAccessToken(authCode)
interceptor.setAuth(oauth) interceptor.setAuth(oauth)
val username = api.getCurrentUser() val username = api.getCurrentUser()
saveCredentials(username, oauth.access_token) saveCredentials(username, oauth.accessToken)
} catch (e: Throwable) { } catch (e: Throwable) {
logger.error(e) { "oauth err" } logger.error(e) { "oauth err" }
logout() logout()
@@ -165,13 +162,13 @@ class MyAnimeList(
interceptor.setAuth(null) interceptor.setAuth(null)
} }
fun saveOAuth(oAuth: OAuth?) { fun saveOAuth(oAuth: MALOAuth?) {
trackPreferences.setTrackToken(this, json.encodeToString(oAuth)) trackPreferences.setTrackToken(this, json.encodeToString(oAuth))
} }
fun loadOAuth(): OAuth? = fun loadOAuth(): MALOAuth? =
try { try {
json.decodeFromString<OAuth>(trackPreferences.getTrackToken(this)!!) json.decodeFromString<MALOAuth>(trackPreferences.getTrackToken(this)!!)
} catch (e: Exception) { } catch (e: Exception) {
logger.error(e) { "loadOAuth err" } logger.error(e) { "loadOAuth err" }
null null

View File

@@ -2,6 +2,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist
import android.net.Uri import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.awaitSuccess
@@ -11,15 +12,6 @@ import eu.kanade.tachiyomi.util.lang.withIOContext
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.serialization.json.Json 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.FormBody
import okhttp3.Headers import okhttp3.Headers
import okhttp3.OkHttpClient 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.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track 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.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 uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@@ -40,7 +39,7 @@ class MyAnimeListApi(
private val authClient = client.newBuilder().addInterceptor(interceptor).build() private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun getAccessToken(authCode: String): OAuth = suspend fun getAccessToken(authCode: String): MALOAuth =
withIOContext { withIOContext {
val formBody: RequestBody = val formBody: RequestBody =
FormBody FormBody
@@ -70,8 +69,8 @@ class MyAnimeListApi(
authClient authClient
.newCall(request) .newCall(request)
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALUser>()
.let { it["name"]!!.jsonPrimitive.content } .name
} }
} }
@@ -89,17 +88,11 @@ class MyAnimeListApi(
authClient authClient
.newCall(GET(url.toString())) .newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALSearchResult>()
.let { .data
it["data"]!! .map { async { getMangaDetails(it.node.id) } }
.jsonArray .awaitAll()
.map { data -> data.jsonObject["node"]!!.jsonObject } .filter { !it.publishing_type.contains("novel") }
.map { node ->
val id = node["id"]!!.jsonPrimitive.int
async { getMangaDetails(id) }
}.awaitAll()
.filter { trackSearch -> !trackSearch.publishing_type.contains("novel") }
}
} }
} }
@@ -110,33 +103,27 @@ class MyAnimeListApi(
.toUri() .toUri()
.buildUpon() .buildUpon()
.appendPath(id.toString()) .appendPath(id.toString())
.appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date") .appendQueryParameter(
.build() "fields",
"id,title,synopsis,num_chapters,mean,main_picture,status,media_type,start_date",
).build()
with(json) { with(json) {
authClient authClient
.newCall(GET(url.toString())) .newCall(GET(url.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALManga>()
.let { .let {
val obj = it.jsonObject
TrackSearch.create(TrackerManager.MYANIMELIST).apply { TrackSearch.create(TrackerManager.MYANIMELIST).apply {
media_id = obj["id"]!!.jsonPrimitive.long remote_id = it.id
title = obj["title"]!!.jsonPrimitive.content title = it.title
summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" summary = it.synopsis
total_chapters = obj["num_chapters"]!!.jsonPrimitive.int total_chapters = it.numChapters
cover_url = score = it.mean
obj["main_picture"] cover_url = it.covers?.large.orEmpty()
?.jsonObject tracking_url = "https://myanimelist.net/manga/$remote_id"
?.get("large") publishing_status = it.status.replace("_", " ")
?.jsonPrimitive publishing_type = it.mediaType.replace("_", " ")
?.content start_date = it.startDate ?: ""
?: ""
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 ?: ""
} }
} }
} }
@@ -161,30 +148,25 @@ class MyAnimeListApi(
val request = val request =
Request Request
.Builder() .Builder()
.url(mangaUrl(track.media_id).toString()) .url(mangaUrl(track.remote_id).toString())
.put(formBodyBuilder.build()) .put(formBodyBuilder.build())
.build() .build()
with(json) { with(json) {
authClient authClient
.newCall(request) .newCall(request)
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALListItemStatus>()
.let { parseMangaItem(it, track) } .let { parseMangaItem(it, track) }
} }
} }
suspend fun deleteItem(track: Track) = suspend fun deleteItem(track: Track) {
withIOContext { withIOContext {
val request =
Request
.Builder()
.url(mangaUrl(track.media_id).toString())
.delete()
.build()
authClient authClient
.newCall(request) .newCall(DELETE(mangaUrl(track.remote_id).toString()))
.awaitSuccess() .awaitSuccess()
} }
}
suspend fun findListItem(track: Track): Track? = suspend fun findListItem(track: Track): Track? =
withIOContext { withIOContext {
@@ -192,19 +174,17 @@ class MyAnimeListApi(
"$BASE_API_URL/manga" "$BASE_API_URL/manga"
.toUri() .toUri()
.buildUpon() .buildUpon()
.appendPath(track.media_id.toString()) .appendPath(track.remote_id.toString())
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}") .appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
.build() .build()
with(json) { with(json) {
authClient authClient
.newCall(GET(uri.toString())) .newCall(GET(uri.toString()))
.awaitSuccess() .awaitSuccess()
.parseAs<JsonObject>() .parseAs<MALListItem>()
.let { obj -> .let { item ->
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int track.total_chapters = item.numChapters
obj.jsonObject["my_list_status"]?.jsonObject?.let { item.myListStatus?.let { parseMangaItem(it, track) }
parseMangaItem(it, track)
}
} }
} }
} }
@@ -214,39 +194,23 @@ class MyAnimeListApi(
offset: Int = 0, offset: Int = 0,
): List<TrackSearch> = ): List<TrackSearch> =
withIOContext { withIOContext {
val json = getListPage(offset) val myListSearchResult = getListPage(offset)
val obj = json.jsonObject
val matches = val matches =
obj["data"]!! myListSearchResult.data
.jsonArray .filter { it.node.title.contains(query, ignoreCase = true) }
.filter { .map { async { getMangaDetails(it.node.id) } }
it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains( .awaitAll()
query,
ignoreCase = true,
)
}.map {
val id =
it.jsonObject["node"]!!
.jsonObject["id"]!!
.jsonPrimitive.int
async { getMangaDetails(id) }
}.awaitAll()
// Check next page if there's more // Check next page if there's more
if (!obj["paging"]!! if (!myListSearchResult.paging.next.isNullOrBlank()) {
.jsonObject["next"]
?.jsonPrimitive
?.contentOrNull
.isNullOrBlank()
) {
matches + findListItems(query, offset + LIST_PAGINATION_AMOUNT) matches + findListItems(query, offset + LIST_PAGINATION_AMOUNT)
} else { } else {
matches matches
} }
} }
private suspend fun getListPage(offset: Int): JsonObject = private suspend fun getListPage(offset: Int): MALUserSearchResult =
withIOContext { withIOContext {
val urlBuilder = val urlBuilder =
"$BASE_API_URL/users/@me/mangalist" "$BASE_API_URL/users/@me/mangalist"
@@ -273,22 +237,16 @@ class MyAnimeListApi(
} }
private fun parseMangaItem( private fun parseMangaItem(
response: JsonObject, listStatus: MALListItemStatus,
track: Track, track: Track,
): Track { ): Track =
val obj = response.jsonObject track.apply {
return track.apply { val isRereading = listStatus.isRereading
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean status = if (isRereading) MyAnimeList.REREADING else getStatus(listStatus.status)
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]?.jsonPrimitive?.content) last_chapter_read = listStatus.numChaptersRead
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.float score = listStatus.score.toDouble()
score = obj["score"]!!.jsonPrimitive.int.toFloat() listStatus.startDate?.let { started_reading_date = parseDate(it) }
obj["start_date"]?.let { listStatus.finishDate?.let { finished_reading_date = parseDate(it) }
started_reading_date = parseDate(it.jsonPrimitive.content)
}
obj["finish_date"]?.let {
finished_reading_date = parseDate(it.jsonPrimitive.content)
}
}
} }
private fun parseDate(isoDate: String): Long = SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L 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") .appendPath("my_list_status")
.build() .build()
fun refreshTokenRequest(oauth: OAuth): Request { fun refreshTokenRequest(oauth: MALOAuth): Request {
val formBody: RequestBody = val formBody: RequestBody =
FormBody FormBody
.Builder() .Builder()
.add("client_id", CLIENT_ID) .add("client_id", CLIENT_ID)
.add("refresh_token", oauth.refresh_token) .add("refresh_token", oauth.refreshToken)
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.build() .build()
@@ -347,7 +305,7 @@ class MyAnimeListApi(
val headers = val headers =
Headers Headers
.Builder() .Builder()
.add("Authorization", "Bearer ${oauth.access_token}") .add("Authorization", "Bearer ${oauth.accessToken}")
.build() .build()
return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers) return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers)

View File

@@ -5,8 +5,7 @@ import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALOAuth
import suwayomi.tachidesk.manga.impl.track.tracker.TokenRefreshFailed
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
@@ -15,11 +14,12 @@ class MyAnimeListInterceptor(
) : Interceptor { ) : Interceptor {
private val json: Json by injectLazy() 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 { override fun intercept(chain: Interceptor.Chain): Response {
if (myanimelist.getIfAuthExpired()) { if (tokenExpired) {
throw TokenExpired() throw MALTokenExpired()
} }
val originalRequest = chain.request() val originalRequest = chain.request()
@@ -35,7 +35,7 @@ class MyAnimeListInterceptor(
val authRequest = val authRequest =
originalRequest originalRequest
.newBuilder() .newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}") .addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
.header("User-Agent", "Suwayomi v${AppInfo.getVersionName()}") .header("User-Agent", "Suwayomi v${AppInfo.getVersionName()}")
.build() .build()
@@ -46,37 +46,44 @@ class MyAnimeListInterceptor(
* Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token * Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
* and the oauth object. * and the oauth object.
*/ */
fun setAuth(oauth: OAuth?) { fun setAuth(oauth: MALOAuth?) {
this.oauth = oauth this.oauth = oauth
myanimelist.saveOAuth(oauth) myanimelist.saveOAuth(oauth)
} }
private fun refreshToken(chain: Interceptor.Chain): OAuth = private fun refreshToken(chain: Interceptor.Chain): MALOAuth =
synchronized(this) { synchronized(this) {
if (myanimelist.getIfAuthExpired()) throw TokenExpired() if (tokenExpired) throw MALTokenExpired()
oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it } oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it }
val response = val response =
try { try {
chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!)) chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!))
} catch (_: Throwable) { } catch (_: Throwable) {
throw TokenRefreshFailed() throw MALTokenRefreshFailed()
} }
if (response.code == 401) { if (response.code == 401) {
myanimelist.setAuthExpired() myanimelist.setAuthExpired()
throw TokenExpired() throw MALTokenExpired()
} }
return runCatching { return runCatching {
if (response.isSuccessful) { if (response.isSuccessful) {
with(json) { response.parseAs<OAuth>() } with(json) { response.parseAs<MALOAuth>() }
} else { } else {
response.close() response.close()
null null
} }
}.getOrNull() }.getOrNull()
?.also(::setAuth) ?.also {
?: throw TokenRefreshFailed() this.oauth = it
myanimelist.saveOAuth(it)
}
?: throw MALTokenRefreshFailed()
} }
} }
class MALTokenRefreshFailed : IOException("MAL: Failed to refresh account token")
class MALTokenExpired : IOException("MAL: Login has expired")

View File

@@ -1,19 +1,7 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track 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() = fun Track.toMyAnimeListStatus() =
when (status) { when (status) {
MyAnimeList.READING -> "reading" MyAnimeList.READING -> "reading"

View File

@@ -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?,
)

View File

@@ -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 = "",
)

View File

@@ -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
}

View File

@@ -0,0 +1,18 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto
import kotlinx.serialization.Serializable
@Serializable
data class MALSearchResult(
val data: List<MALSearchResultNode>,
)
@Serializable
data class MALSearchResultNode(
val node: MALSearchResultItem,
)
@Serializable
data class MALSearchResultItem(
val id: Int,
)

View File

@@ -0,0 +1,8 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto
import kotlinx.serialization.Serializable
@Serializable
data class MALUser(
val name: String,
)

View File

@@ -0,0 +1,25 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto
import kotlinx.serialization.Serializable
@Serializable
data class MALUserSearchResult(
val data: List<MALUserSearchItem>,
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,
)

View File

@@ -22,4 +22,5 @@ data class TrackRecordDataClass(
val remoteUrl: String, val remoteUrl: String,
val startDate: Long, val startDate: Long,
val finishDate: Long, val finishDate: Long,
val private: Boolean,
) )

View File

@@ -14,7 +14,9 @@ data class TrackSearchDataClass(
val id: Int, val id: Int,
val trackerId: Int, val trackerId: Int,
val remoteId: Long, val remoteId: Long,
val libraryId: Long?,
val title: String, val title: String,
val lastChapterRead: Double,
val totalChapters: Int, val totalChapters: Int,
val trackingUrl: String, val trackingUrl: String,
val coverUrl: String, val coverUrl: String,
@@ -22,4 +24,10 @@ data class TrackSearchDataClass(
val publishingStatus: String, val publishingStatus: String,
val publishingType: String, val publishingType: String,
val startDate: String, val startDate: String,
val status: Int,
val score: Double,
var scoreString: String?,
val startedReadingDate: Long,
val finishedReadingDate: Long,
val private: Boolean,
) )

View File

@@ -24,4 +24,5 @@ object TrackRecordTable : IntIdTable() {
val remoteUrl = varchar("remote_url", 512) val remoteUrl = varchar("remote_url", 512)
val startDate = long("start_date") val startDate = long("start_date")
val finishDate = long("finish_date") val finishDate = long("finish_date")
val private = bool("private").default(false)
} }

View File

@@ -29,13 +29,22 @@ object TrackSearchTable : IntIdTable() {
val publishingStatus = truncatingVarchar("publishing_status", 512) val publishingStatus = truncatingVarchar("publishing_status", 512)
val publishingType = truncatingVarchar("publishing_type", 512) val publishingType = truncatingVarchar("publishing_type", 512)
val startDate = truncatingVarchar("start_date", 128) 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<TrackSearch>.insertAll(): List<ResultRow> { fun List<TrackSearch>.insertAll(): List<ResultRow> {
if (isEmpty()) return emptyList() if (isEmpty()) return emptyList()
return transaction { return transaction {
val trackerIds = map { it.sync_id }.toSet() val trackerIds = map { it.tracker_id }.toSet()
val remoteIds = map { it.media_id }.toSet() val remoteIds = map { it.remote_id }.toSet()
val existing = val existing =
transaction { transaction {
TrackSearchTable TrackSearchTable
@@ -50,8 +59,8 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
forEach { trackSearch -> forEach { trackSearch ->
val existingRow = val existingRow =
existing.find { existing.find {
it[TrackSearchTable.trackerId] == trackSearch.sync_id && it[TrackSearchTable.trackerId] == trackSearch.tracker_id &&
it[TrackSearchTable.remoteId] == trackSearch.media_id it[TrackSearchTable.remoteId] == trackSearch.remote_id
} }
grouped grouped
.getOrPut(existingRow != null) { mutableListOf() } .getOrPut(existingRow != null) { mutableListOf() }
@@ -72,6 +81,15 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
this[TrackSearchTable.publishingStatus] = trackSearch.publishing_status this[TrackSearchTable.publishingStatus] = trackSearch.publishing_status
this[TrackSearchTable.publishingType] = trackSearch.publishing_type this[TrackSearchTable.publishingType] = trackSearch.publishing_type
this[TrackSearchTable.startDate] = trackSearch.start_date 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) execute(this@transaction)
} }
@@ -79,8 +97,8 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
val insertedRows = val insertedRows =
if (!toInsert.isNullOrEmpty()) { if (!toInsert.isNullOrEmpty()) {
TrackSearchTable.batchInsert(toInsert) { TrackSearchTable.batchInsert(toInsert) {
this[TrackSearchTable.trackerId] = it.sync_id this[TrackSearchTable.trackerId] = it.tracker_id
this[TrackSearchTable.remoteId] = it.media_id this[TrackSearchTable.remoteId] = it.remote_id
this[TrackSearchTable.title] = it.title this[TrackSearchTable.title] = it.title
this[TrackSearchTable.totalChapters] = it.total_chapters this[TrackSearchTable.totalChapters] = it.total_chapters
this[TrackSearchTable.trackingUrl] = it.tracking_url this[TrackSearchTable.trackingUrl] = it.tracking_url
@@ -89,6 +107,15 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
this[TrackSearchTable.publishingStatus] = it.publishing_status this[TrackSearchTable.publishingStatus] = it.publishing_status
this[TrackSearchTable.publishingType] = it.publishing_type this[TrackSearchTable.publishingType] = it.publishing_type
this[TrackSearchTable.startDate] = it.start_date 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 { } else {
emptyList() emptyList()
@@ -104,8 +131,8 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
(insertedRows + updatedRows) (insertedRows + updatedRows)
.sortedBy { row -> .sortedBy { row ->
indexOfFirst { indexOfFirst {
it.sync_id == row[TrackSearchTable.trackerId] && it.tracker_id == row[TrackSearchTable.trackerId] &&
it.media_id == row[TrackSearchTable.remoteId] it.remote_id == row[TrackSearchTable.remoteId]
} }
} }
} }

View File

@@ -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()
}