mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2025-12-10 06:42:07 +01:00
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:
@@ -22,7 +22,9 @@ import suwayomi.tachidesk.graphql.types.TrackStatusType
|
||||
import suwayomi.tachidesk.graphql.types.TrackerType
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrackSearch
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
|
||||
import suwayomi.tachidesk.manga.model.table.TrackSearchTable
|
||||
import suwayomi.tachidesk.server.JavalinSetup.future
|
||||
|
||||
class TrackerDataLoader : KotlinDataLoader<Int, TrackerType> {
|
||||
@@ -116,7 +118,30 @@ class DisplayScoreForTrackRecordDataLoader : KotlinDataLoader<Int, String> {
|
||||
.toList()
|
||||
.map { it.toTrack() }
|
||||
.associateBy { it.id!! }
|
||||
.mapValues { TrackerManager.getTracker(it.value.sync_id)?.displayScore(it.value) }
|
||||
.mapValues { TrackerManager.getTracker(it.value.tracker_id)?.displayScore(it.value) }
|
||||
|
||||
ids.map { trackRecords[it] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DisplayScoreForTrackSearchDataLoader : KotlinDataLoader<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] }
|
||||
}
|
||||
|
||||
@@ -108,6 +108,8 @@ class TrackMutation {
|
||||
val mangaId: Int,
|
||||
val trackerId: Int,
|
||||
val remoteId: Long,
|
||||
@GraphQLDescription("This will only work if the tracker of the track record supports private tracking")
|
||||
val private: Boolean? = null,
|
||||
)
|
||||
|
||||
data class BindTrackPayload(
|
||||
@@ -116,13 +118,14 @@ class TrackMutation {
|
||||
)
|
||||
|
||||
fun bindTrack(input: BindTrackInput): CompletableFuture<BindTrackPayload> {
|
||||
val (clientMutationId, mangaId, trackerId, remoteId) = input
|
||||
val (clientMutationId, mangaId, trackerId, remoteId, private) = input
|
||||
|
||||
return future {
|
||||
Track.bind(
|
||||
mangaId,
|
||||
trackerId,
|
||||
remoteId,
|
||||
private ?: false,
|
||||
)
|
||||
val trackRecord =
|
||||
transaction {
|
||||
@@ -238,8 +241,12 @@ class TrackMutation {
|
||||
val status: Int? = null,
|
||||
val lastChapterRead: Double? = null,
|
||||
val scoreString: String? = null,
|
||||
@GraphQLDescription("This will only work if the tracker of the track record supports reading dates")
|
||||
val startDate: Long? = null,
|
||||
@GraphQLDescription("This will only work if the tracker of the track record supports reading dates")
|
||||
val finishDate: Long? = null,
|
||||
@GraphQLDescription("This will only work if the tracker of the track record supports private tracking")
|
||||
val private: Boolean? = null,
|
||||
@GraphQLDeprecated("Replaced with \"unbindTrack\" mutation", replaceWith = ReplaceWith("unbindTrack"))
|
||||
val unbind: Boolean? = null,
|
||||
)
|
||||
@@ -260,6 +267,7 @@ class TrackMutation {
|
||||
input.startDate,
|
||||
input.finishDate,
|
||||
input.unbind,
|
||||
input.private,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.ChapterDataLoader
|
||||
import suwayomi.tachidesk.graphql.dataLoaders.ChapterMetaDataLoader
|
||||
import suwayomi.tachidesk.graphql.dataLoaders.ChaptersForMangaDataLoader
|
||||
import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackRecordDataLoader
|
||||
import suwayomi.tachidesk.graphql.dataLoaders.DisplayScoreForTrackSearchDataLoader
|
||||
import suwayomi.tachidesk.graphql.dataLoaders.DownloadedChapterCountForMangaDataLoader
|
||||
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionDataLoader
|
||||
import suwayomi.tachidesk.graphql.dataLoaders.ExtensionForSourceDataLoader
|
||||
@@ -83,6 +84,7 @@ class TachideskDataLoaderRegistryFactory {
|
||||
TrackerTokenExpiredDataLoader(),
|
||||
TrackRecordsForMangaIdDataLoader(),
|
||||
DisplayScoreForTrackRecordDataLoader(),
|
||||
DisplayScoreForTrackSearchDataLoader(),
|
||||
TrackRecordsForTrackerIdDataLoader(),
|
||||
TrackRecordDataLoader(),
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import suwayomi.tachidesk.graphql.server.primitives.Node
|
||||
import suwayomi.tachidesk.graphql.server.primitives.NodeList
|
||||
import suwayomi.tachidesk.graphql.server.primitives.PageInfo
|
||||
import suwayomi.tachidesk.manga.impl.track.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
|
||||
import suwayomi.tachidesk.manga.model.table.TrackSearchTable
|
||||
@@ -20,7 +21,9 @@ class TrackerType(
|
||||
val icon: String,
|
||||
val isLoggedIn: Boolean,
|
||||
val authUrl: String?,
|
||||
val supportsTrackDeletion: Boolean?,
|
||||
val supportsTrackDeletion: Boolean,
|
||||
val supportsReadingDates: Boolean,
|
||||
val supportsPrivateTracking: Boolean,
|
||||
) : Node {
|
||||
constructor(tracker: Tracker) : this(
|
||||
tracker.isLoggedIn,
|
||||
@@ -37,7 +40,9 @@ class TrackerType(
|
||||
} else {
|
||||
tracker.authUrl()
|
||||
},
|
||||
tracker.supportsTrackDeletion,
|
||||
tracker is DeletableTracker,
|
||||
tracker.supportsReadingDates,
|
||||
tracker.supportsPrivateTracking,
|
||||
)
|
||||
|
||||
fun statuses(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<List<TrackStatusType>> =
|
||||
@@ -72,6 +77,7 @@ class TrackRecordType(
|
||||
val remoteUrl: String,
|
||||
val startDate: Long,
|
||||
val finishDate: Long,
|
||||
val private: Boolean,
|
||||
) : Node {
|
||||
constructor(row: ResultRow) : this(
|
||||
row[TrackRecordTable.id].value,
|
||||
@@ -87,6 +93,7 @@ class TrackRecordType(
|
||||
row[TrackRecordTable.remoteUrl],
|
||||
row[TrackRecordTable.startDate],
|
||||
row[TrackRecordTable.finishDate],
|
||||
row[TrackRecordTable.private],
|
||||
)
|
||||
|
||||
fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<String> =
|
||||
@@ -103,7 +110,9 @@ class TrackSearchType(
|
||||
val id: Int,
|
||||
val trackerId: Int,
|
||||
val remoteId: Long,
|
||||
val libraryId: Long?,
|
||||
val title: String,
|
||||
val lastChapterRead: Double,
|
||||
val totalChapters: Int,
|
||||
val trackingUrl: String,
|
||||
val coverUrl: String,
|
||||
@@ -111,12 +120,19 @@ class TrackSearchType(
|
||||
val publishingStatus: String,
|
||||
val publishingType: String,
|
||||
val startDate: String,
|
||||
val status: Int,
|
||||
val score: Double,
|
||||
val startedReadingDate: Long,
|
||||
val finishedReadingDate: Long,
|
||||
val private: Boolean,
|
||||
) {
|
||||
constructor(row: ResultRow) : this(
|
||||
row[TrackSearchTable.id].value,
|
||||
row[TrackSearchTable.trackerId],
|
||||
row[TrackSearchTable.remoteId],
|
||||
row[TrackSearchTable.libraryId],
|
||||
row[TrackSearchTable.title],
|
||||
row[TrackSearchTable.lastChapterRead],
|
||||
row[TrackSearchTable.totalChapters],
|
||||
row[TrackSearchTable.trackingUrl],
|
||||
row[TrackSearchTable.coverUrl],
|
||||
@@ -124,10 +140,18 @@ class TrackSearchType(
|
||||
row[TrackSearchTable.publishingStatus],
|
||||
row[TrackSearchTable.publishingType],
|
||||
row[TrackSearchTable.startDate],
|
||||
row[TrackSearchTable.status],
|
||||
row[TrackSearchTable.score],
|
||||
row[TrackSearchTable.startedReadingDate],
|
||||
row[TrackSearchTable.finishedReadingDate],
|
||||
row[TrackSearchTable.private],
|
||||
)
|
||||
|
||||
fun tracker(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<TrackerType> =
|
||||
dataFetchingEnvironment.getValueFromDataLoader<Int, TrackerType>("TrackerDataLoader", trackerId)
|
||||
|
||||
fun displayScore(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture<String> =
|
||||
dataFetchingEnvironment.getValueFromDataLoader<Int, String>("DisplayScoreForTrackSearchDataLoader", id)
|
||||
}
|
||||
|
||||
data class TrackRecordNodeList(
|
||||
|
||||
@@ -114,15 +114,16 @@ object TrackController {
|
||||
queryParam<Int>("mangaId"),
|
||||
queryParam<Int>("trackerId"),
|
||||
queryParam<String>("remoteId"),
|
||||
queryParam<Boolean>("private"),
|
||||
documentWith = {
|
||||
withOperation {
|
||||
summary("Track Record Bind")
|
||||
description("Bind a Track Record to a Manga")
|
||||
}
|
||||
},
|
||||
behaviorOf = { ctx, mangaId, trackerId, remoteId ->
|
||||
behaviorOf = { ctx, mangaId, trackerId, remoteId, private ->
|
||||
ctx.future {
|
||||
future { Track.bind(mangaId, trackerId, remoteId.toLong()) }
|
||||
future { Track.bind(mangaId, trackerId, remoteId.toLong(), private) }
|
||||
.thenApply { ctx.status(HttpStatus.OK) }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -473,14 +473,14 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
Tracker
|
||||
.getTrackRecordsByMangaId(mangaId)
|
||||
.mapNotNull { it.record?.toTrack() }
|
||||
.associateBy { it.sync_id }
|
||||
.associateBy { it.tracker_id }
|
||||
|
||||
val (existingTracks, newTracks) =
|
||||
tracks
|
||||
.mapNotNull { backupTrack ->
|
||||
val track = backupTrack.toTrack(mangaId)
|
||||
|
||||
val isUnsupportedTracker = TrackerManager.getTracker(track.sync_id) == null
|
||||
val isUnsupportedTracker = TrackerManager.getTracker(track.tracker_id) == null
|
||||
if (isUnsupportedTracker) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
@@ -495,7 +495,7 @@ object ProtoBackupImport : ProtoBackupBase() {
|
||||
}
|
||||
|
||||
dbTrack.also {
|
||||
it.media_id = track.media_id
|
||||
it.remote_id = track.remote_id
|
||||
it.library_id = track.library_id
|
||||
it.last_chapter_read = max(dbTrack.last_chapter_read, track.last_chapter_read)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ data class BackupTracking(
|
||||
@ProtoNumber(10) var startedReadingDate: Long = 0,
|
||||
// finishedReadingDate is called endReadTime in 1.x
|
||||
@ProtoNumber(11) var finishedReadingDate: Long = 0,
|
||||
@ProtoNumber(12) var private: Boolean = false,
|
||||
@ProtoNumber(100) var mediaId: Long = 0,
|
||||
) {
|
||||
fun getTrackingImpl(): TrackImpl =
|
||||
|
||||
@@ -16,8 +16,8 @@ import org.jetbrains.exposed.sql.deleteWhere
|
||||
import org.jetbrains.exposed.sql.selectAll
|
||||
import org.jetbrains.exposed.sql.statements.BatchUpdateStatement
|
||||
import org.jetbrains.exposed.sql.transactions.transaction
|
||||
import org.jetbrains.exposed.sql.update
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService
|
||||
import org.jsoup.Jsoup
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.toTrack
|
||||
@@ -31,6 +31,7 @@ import suwayomi.tachidesk.manga.model.table.TrackRecordTable.finishDate
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.lastChapterRead
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.libraryId
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.mangaId
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.private
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteId
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.score
|
||||
@@ -100,7 +101,7 @@ object Track {
|
||||
if (record != null) {
|
||||
val track =
|
||||
Track.create(it.id).also { t ->
|
||||
t.score = record.score.toFloat()
|
||||
t.score = record.score
|
||||
}
|
||||
record.scoreString = it.displayScore(track)
|
||||
}
|
||||
@@ -124,7 +125,9 @@ object Track {
|
||||
id = it[TrackSearchTable.id].value,
|
||||
trackerId = it[TrackSearchTable.trackerId],
|
||||
remoteId = it[TrackSearchTable.remoteId],
|
||||
libraryId = it[TrackSearchTable.libraryId],
|
||||
title = it[TrackSearchTable.title],
|
||||
lastChapterRead = it[TrackSearchTable.lastChapterRead],
|
||||
totalChapters = it[TrackSearchTable.totalChapters],
|
||||
trackingUrl = it[TrackSearchTable.trackingUrl],
|
||||
coverUrl = it[TrackSearchTable.coverUrl],
|
||||
@@ -132,6 +135,12 @@ object Track {
|
||||
publishingStatus = it[TrackSearchTable.publishingStatus],
|
||||
publishingType = it[TrackSearchTable.publishingType],
|
||||
startDate = it[TrackSearchTable.startDate],
|
||||
status = it[TrackSearchTable.status],
|
||||
score = it[TrackSearchTable.score],
|
||||
scoreString = null,
|
||||
startedReadingDate = it[TrackSearchTable.startedReadingDate],
|
||||
finishedReadingDate = it[TrackSearchTable.finishedReadingDate],
|
||||
private = it[TrackSearchTable.private],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +148,7 @@ object Track {
|
||||
private fun ResultRow.toTrackFromSearch(mangaId: Int): Track =
|
||||
Track.create(this[TrackSearchTable.trackerId]).also {
|
||||
it.manga_id = mangaId
|
||||
it.media_id = this[TrackSearchTable.remoteId]
|
||||
it.remote_id = this[TrackSearchTable.remoteId]
|
||||
it.title = this[TrackSearchTable.title]
|
||||
it.total_chapters = this[TrackSearchTable.totalChapters]
|
||||
it.tracking_url = this[TrackSearchTable.trackingUrl]
|
||||
@@ -149,6 +158,7 @@ object Track {
|
||||
mangaId: Int,
|
||||
trackerId: Int,
|
||||
remoteId: Long,
|
||||
private: Boolean,
|
||||
) {
|
||||
val track =
|
||||
transaction {
|
||||
@@ -167,7 +177,8 @@ object Track {
|
||||
}.first()
|
||||
.toTrack()
|
||||
.apply {
|
||||
manga_id = mangaId
|
||||
this.manga_id = mangaId
|
||||
this.private = private
|
||||
}
|
||||
}
|
||||
val tracker = TrackerManager.getTracker(trackerId)!!
|
||||
@@ -234,7 +245,7 @@ object Track {
|
||||
|
||||
val tracker = TrackerManager.getTracker(recordDb[TrackRecordTable.trackerId])
|
||||
|
||||
if (deleteRemoteTrack == true && tracker is DeletableTrackService) {
|
||||
if (deleteRemoteTrack == true && tracker is DeletableTracker) {
|
||||
tracker.delete(recordDb.toTrack())
|
||||
}
|
||||
|
||||
@@ -278,8 +289,7 @@ object Track {
|
||||
}
|
||||
if (input.scoreString != null) {
|
||||
val score = tracker.indexToScore(tracker.getScoreList().indexOf(input.scoreString))
|
||||
// conversion issues between Float <-> Double so convert to string before double
|
||||
recordDb[TrackRecordTable.score] = score.toString().toDouble()
|
||||
recordDb[TrackRecordTable.score] = score
|
||||
}
|
||||
if (input.startDate != null) {
|
||||
recordDb[TrackRecordTable.startDate] = input.startDate
|
||||
@@ -287,6 +297,9 @@ object Track {
|
||||
if (input.finishDate != null) {
|
||||
recordDb[TrackRecordTable.finishDate] = input.finishDate
|
||||
}
|
||||
if (input.private != null) {
|
||||
recordDb[TrackRecordTable.private] = input.private
|
||||
}
|
||||
|
||||
val track = recordDb.toTrack()
|
||||
tracker.update(track)
|
||||
@@ -384,7 +397,7 @@ object Track {
|
||||
log.debug { "remoteLastReadChapter= $lastChapterRead" }
|
||||
|
||||
if (chapterNumber > lastChapterRead) {
|
||||
track.last_chapter_read = chapterNumber.toFloat()
|
||||
track.last_chapter_read = chapterNumber
|
||||
tracker.update(track, true)
|
||||
upsertTrackRecord(track)
|
||||
}
|
||||
@@ -397,7 +410,7 @@ object Track {
|
||||
.selectAll()
|
||||
.where {
|
||||
(TrackRecordTable.mangaId eq track.manga_id) and
|
||||
(TrackRecordTable.trackerId eq track.sync_id)
|
||||
(TrackRecordTable.trackerId eq track.tracker_id)
|
||||
}.singleOrNull()
|
||||
|
||||
if (existingRecord != null) {
|
||||
@@ -416,16 +429,17 @@ object Track {
|
||||
BatchUpdateStatement(TrackRecordTable).apply {
|
||||
tracks.forEach {
|
||||
addBatch(EntityID(it.id!!, TrackRecordTable))
|
||||
this[remoteId] = it.media_id
|
||||
this[remoteId] = it.remote_id
|
||||
this[libraryId] = it.library_id
|
||||
this[title] = it.title
|
||||
this[lastChapterRead] = it.last_chapter_read.toDouble()
|
||||
this[lastChapterRead] = it.last_chapter_read
|
||||
this[totalChapters] = it.total_chapters
|
||||
this[status] = it.status
|
||||
this[score] = it.score.toDouble()
|
||||
this[score] = it.score
|
||||
this[remoteUrl] = it.tracking_url
|
||||
this[startDate] = it.started_reading_date
|
||||
this[finishDate] = it.finished_reading_date
|
||||
this[private] = it.private
|
||||
}
|
||||
execute(this@transaction)
|
||||
}
|
||||
@@ -439,17 +453,18 @@ object Track {
|
||||
TrackRecordTable
|
||||
.batchInsert(tracks) {
|
||||
this[mangaId] = it.manga_id
|
||||
this[trackerId] = it.sync_id
|
||||
this[remoteId] = it.media_id
|
||||
this[trackerId] = it.tracker_id
|
||||
this[remoteId] = it.remote_id
|
||||
this[libraryId] = it.library_id
|
||||
this[title] = it.title
|
||||
this[lastChapterRead] = it.last_chapter_read.toDouble()
|
||||
this[lastChapterRead] = it.last_chapter_read
|
||||
this[totalChapters] = it.total_chapters
|
||||
this[status] = it.status
|
||||
this[score] = it.score.toDouble()
|
||||
this[score] = it.score
|
||||
this[remoteUrl] = it.tracking_url
|
||||
this[startDate] = it.started_reading_date
|
||||
this[finishDate] = it.finished_reading_date
|
||||
this[private] = it.private
|
||||
}.map { it[TrackRecordTable.id].value }
|
||||
}
|
||||
|
||||
@@ -481,5 +496,8 @@ object Track {
|
||||
val startDate: Long? = null,
|
||||
val finishDate: Long? = null,
|
||||
val unbind: Boolean? = null,
|
||||
val private: Boolean? = null,
|
||||
)
|
||||
|
||||
fun String.htmlDecode(): String = Jsoup.parse(this).wholeText()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
/**
|
||||
* For track services api that support deleting a manga entry for a user's list
|
||||
*/
|
||||
interface DeletableTrackService {
|
||||
interface DeletableTracker {
|
||||
suspend fun delete(track: Track)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ abstract class Tracker(
|
||||
// Application and remote support for reading dates
|
||||
open val supportsReadingDates: Boolean = false
|
||||
|
||||
abstract val supportsTrackDeletion: Boolean
|
||||
open val supportsPrivateTracking: Boolean = false
|
||||
|
||||
override fun toString() = "$name ($id) (isLoggedIn= $isLoggedIn, isAuthExpired= ${getIfAuthExpired()})"
|
||||
|
||||
@@ -38,7 +38,7 @@ abstract class Tracker(
|
||||
|
||||
abstract fun getScoreList(): List<String>
|
||||
|
||||
open fun indexToScore(index: Int): Float = index.toFloat()
|
||||
open fun indexToScore(index: Int): Double = index.toDouble()
|
||||
|
||||
abstract fun displayScore(track: Track): String
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ object TrackerPreferences {
|
||||
username: String,
|
||||
password: String,
|
||||
) {
|
||||
logger.debug { "setTrackCredentials: id=${sync.id} username=$username" }
|
||||
preferenceStore
|
||||
.edit()
|
||||
.putString(trackUsername(sync.id), username)
|
||||
@@ -42,7 +41,6 @@ object TrackerPreferences {
|
||||
sync: Tracker,
|
||||
token: String?,
|
||||
) {
|
||||
logger.debug { "setTrackToken: id=${sync.id} token=$token" }
|
||||
if (token == null) {
|
||||
preferenceStore
|
||||
.edit()
|
||||
|
||||
@@ -2,10 +2,10 @@ package suwayomi.tachidesk.manga.impl.track.tracker.anilist
|
||||
|
||||
import android.annotation.StringRes
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALOAuth
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.extractToken
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
@@ -15,7 +15,7 @@ import java.io.IOException
|
||||
class Anilist(
|
||||
id: Int,
|
||||
) : Tracker(id, "AniList"),
|
||||
DeletableTrackService {
|
||||
DeletableTracker {
|
||||
companion object {
|
||||
const val READING = 1
|
||||
const val COMPLETED = 2
|
||||
@@ -31,8 +31,6 @@ class Anilist(
|
||||
const val POINT_3 = "POINT_3"
|
||||
}
|
||||
|
||||
override val supportsTrackDeletion: Boolean = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val interceptor by lazy { AnilistInterceptor(this) }
|
||||
@@ -41,6 +39,8 @@ class Anilist(
|
||||
|
||||
override val supportsReadingDates: Boolean = true
|
||||
|
||||
override val supportsPrivateTracking: Boolean = true
|
||||
|
||||
private val logger = KotlinLogging.logger {}
|
||||
|
||||
override fun getLogo(): String = "/static/tracker/anilist.png"
|
||||
@@ -80,26 +80,26 @@ class Anilist(
|
||||
else -> throw Exception("Unknown score type")
|
||||
}
|
||||
|
||||
override fun indexToScore(index: Int): Float =
|
||||
override fun indexToScore(index: Int): Double =
|
||||
when (trackPreferences.getScoreType(this)) {
|
||||
// 10 point
|
||||
POINT_10 -> index * 10f
|
||||
POINT_10 -> index * 10.0
|
||||
// 100 point
|
||||
POINT_100 -> index.toFloat()
|
||||
POINT_100 -> index.toDouble()
|
||||
// 5 stars
|
||||
POINT_5 ->
|
||||
when (index) {
|
||||
0 -> 0f
|
||||
else -> index * 20f - 10f
|
||||
0 -> 0.0
|
||||
else -> index * 20.0 - 10.0
|
||||
}
|
||||
// Smiley
|
||||
POINT_3 ->
|
||||
when (index) {
|
||||
0 -> 0f
|
||||
else -> index * 25f + 10f
|
||||
0 -> 0.0
|
||||
else -> index * 25.0 + 10.0
|
||||
}
|
||||
// 10 point decimal
|
||||
POINT_10_DECIMAL -> index.toFloat()
|
||||
POINT_10_DECIMAL -> index.toDouble()
|
||||
else -> throw Exception("Unknown score type")
|
||||
}
|
||||
|
||||
@@ -108,17 +108,17 @@ class Anilist(
|
||||
return when (val type = trackPreferences.getScoreType(this)) {
|
||||
POINT_5 ->
|
||||
when (score) {
|
||||
0f -> "0 ★"
|
||||
0.0 -> "0 ★"
|
||||
else -> "${((score + 10) / 20).toInt()} ★"
|
||||
}
|
||||
POINT_3 ->
|
||||
when {
|
||||
score == 0f -> "0"
|
||||
score == 0.0 -> "0"
|
||||
score <= 35 -> "😦"
|
||||
score <= 60 -> "😐"
|
||||
else -> "😊"
|
||||
}
|
||||
else -> track.toAnilistScore(type)
|
||||
else -> track.toApiScore(type)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ class Anilist(
|
||||
track.finished_reading_date = System.currentTimeMillis()
|
||||
} else if (track.status != REREADING) {
|
||||
track.status = READING
|
||||
if (track.last_chapter_read == 1F) {
|
||||
if (track.last_chapter_read == 1.0) {
|
||||
track.started_reading_date = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
@@ -168,19 +168,19 @@ class Anilist(
|
||||
): Track {
|
||||
val remoteTrack = api.findLibManga(track, getUsername().toInt())
|
||||
return if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
|
||||
track.library_id = remoteTrack.library_id
|
||||
|
||||
if (track.status != COMPLETED) {
|
||||
val isRereading = track.status == REREADING
|
||||
track.status = if (isRereading.not() && hasReadChapters) READING else track.status
|
||||
track.status = if (!isRereading && hasReadChapters) READING else track.status
|
||||
}
|
||||
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.status = if (hasReadChapters) READING else PLAN_TO_READ
|
||||
track.score = 0F
|
||||
track.score = 0.0
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
@@ -209,12 +209,11 @@ class Anilist(
|
||||
|
||||
private suspend fun login(token: String) {
|
||||
try {
|
||||
logger.debug { "login $token" }
|
||||
val oauth = api.createOAuth(token)
|
||||
interceptor.setAuth(oauth)
|
||||
val (username, scoreType) = api.getCurrentUser()
|
||||
trackPreferences.setScoreType(this, scoreType)
|
||||
saveCredentials(username.toString(), oauth.access_token)
|
||||
saveCredentials(username.toString(), oauth.accessToken)
|
||||
} catch (e: Throwable) {
|
||||
logger.error(e) { "oauth err" }
|
||||
logout()
|
||||
@@ -228,13 +227,13 @@ class Anilist(
|
||||
interceptor.setAuth(null)
|
||||
}
|
||||
|
||||
fun saveOAuth(oAuth: OAuth?) {
|
||||
fun saveOAuth(oAuth: ALOAuth?) {
|
||||
trackPreferences.setTrackToken(this, json.encodeToString(oAuth))
|
||||
}
|
||||
|
||||
fun loadOAuth(): OAuth? =
|
||||
fun loadOAuth(): ALOAuth? =
|
||||
try {
|
||||
json.decodeFromString<OAuth>(trackPreferences.getTrackToken(this)!!)
|
||||
json.decodeFromString<ALOAuth>(trackPreferences.getTrackToken(this)!!)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "loadOAuth err" }
|
||||
null
|
||||
|
||||
@@ -12,22 +12,21 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALAddMangaResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALCurrentUserResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALOAuth
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALSearchResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALUserListMangaQueryResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.util.Calendar
|
||||
import kotlin.time.Duration.Companion.days
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
class AnilistApi(
|
||||
@@ -47,11 +46,11 @@ class AnilistApi(
|
||||
withIOContext {
|
||||
val query =
|
||||
"""
|
||||
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus) {
|
||||
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status) {
|
||||
| id
|
||||
| status
|
||||
|}
|
||||
|mutation AddManga(${'$'}mangaId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean) {
|
||||
|SaveMediaListEntry (mediaId: ${'$'}mangaId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private) {
|
||||
| id
|
||||
| status
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
@@ -59,9 +58,10 @@ class AnilistApi(
|
||||
buildJsonObject {
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("mangaId", track.media_id)
|
||||
put("mangaId", track.remote_id)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("status", track.toAnilistStatus())
|
||||
put("status", track.toApiStatus())
|
||||
put("private", track.private)
|
||||
}
|
||||
}
|
||||
with(json) {
|
||||
@@ -72,13 +72,9 @@ class AnilistApi(
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<ALAddMangaResult>()
|
||||
.let {
|
||||
track.library_id =
|
||||
it["data"]!!
|
||||
.jsonObject["SaveMediaListEntry"]!!
|
||||
.jsonObject["id"]!!
|
||||
.jsonPrimitive.long
|
||||
track.library_id = it.data.entry.id
|
||||
track
|
||||
}
|
||||
}
|
||||
@@ -89,11 +85,11 @@ class AnilistApi(
|
||||
val query =
|
||||
"""
|
||||
|mutation UpdateManga(
|
||||
|${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus,
|
||||
|${'$'}listId: Int, ${'$'}progress: Int, ${'$'}status: MediaListStatus, ${'$'}private: Boolean,
|
||||
|${'$'}score: Int, ${'$'}startedAt: FuzzyDateInput, ${'$'}completedAt: FuzzyDateInput
|
||||
|) {
|
||||
|SaveMediaListEntry(
|
||||
|id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status,
|
||||
|id: ${'$'}listId, progress: ${'$'}progress, status: ${'$'}status, private: ${'$'}private,
|
||||
|scoreRaw: ${'$'}score, startedAt: ${'$'}startedAt, completedAt: ${'$'}completedAt
|
||||
|) {
|
||||
|id
|
||||
@@ -109,10 +105,11 @@ class AnilistApi(
|
||||
putJsonObject("variables") {
|
||||
put("listId", track.library_id)
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("status", track.toAnilistStatus())
|
||||
put("status", track.toApiStatus())
|
||||
put("score", track.score.toInt())
|
||||
put("startedAt", createDate(track.started_reading_date))
|
||||
put("completedAt", createDate(track.finished_reading_date))
|
||||
put("private", track.private)
|
||||
}
|
||||
}
|
||||
authClient
|
||||
@@ -121,14 +118,14 @@ class AnilistApi(
|
||||
track
|
||||
}
|
||||
|
||||
suspend fun deleteLibManga(track: Track) =
|
||||
suspend fun deleteLibManga(track: Track) {
|
||||
withIOContext {
|
||||
val query =
|
||||
"""
|
||||
|mutation DeleteManga(${'$'}listId: Int) {
|
||||
|DeleteMediaListEntry(id: ${'$'}listId) {
|
||||
|DeleteMediaListEntry(id: ${'$'}listId) {
|
||||
|deleted
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|
|
||||
""".trimMargin()
|
||||
@@ -143,6 +140,7 @@ class AnilistApi(
|
||||
.newCall(POST(API_URL, body = payload.toString().toRequestBody(jsonMime)))
|
||||
.awaitSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(search: String): List<TrackSearch> =
|
||||
withIOContext {
|
||||
@@ -152,6 +150,19 @@ class AnilistApi(
|
||||
|Page (perPage: 50) {
|
||||
|media(search: ${'$'}query, type: MANGA, format_not_in: [NOVEL]) {
|
||||
|id
|
||||
|staff {
|
||||
|edges {
|
||||
|role
|
||||
|id
|
||||
|node {
|
||||
|name {
|
||||
|full
|
||||
|userPreferred
|
||||
|native
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|title {
|
||||
|userPreferred
|
||||
|}
|
||||
@@ -167,6 +178,7 @@ class AnilistApi(
|
||||
|month
|
||||
|day
|
||||
|}
|
||||
|averageScore
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
@@ -187,14 +199,9 @@ class AnilistApi(
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["media"]!!.jsonArray
|
||||
val entries = media.map { jsonToALManga(it.jsonObject) }
|
||||
entries.map { it.toTrack() }
|
||||
}
|
||||
.parseAs<ALSearchResult>()
|
||||
.data.page.media
|
||||
.map { it.toALManga().toTrack() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +219,7 @@ class AnilistApi(
|
||||
|status
|
||||
|scoreRaw: score(format: POINT_100)
|
||||
|progress
|
||||
|private
|
||||
|startedAt {
|
||||
|year
|
||||
|month
|
||||
@@ -239,6 +247,19 @@ class AnilistApi(
|
||||
|month
|
||||
|day
|
||||
|}
|
||||
|staff {
|
||||
|edges {
|
||||
|role
|
||||
|id
|
||||
|node {
|
||||
|name {
|
||||
|full
|
||||
|userPreferred
|
||||
|native
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
|}
|
||||
@@ -250,7 +271,7 @@ class AnilistApi(
|
||||
put("query", query)
|
||||
putJsonObject("variables") {
|
||||
put("id", userid)
|
||||
put("manga_id", track.media_id)
|
||||
put("manga_id", track.remote_id)
|
||||
}
|
||||
}
|
||||
with(json) {
|
||||
@@ -261,24 +282,20 @@ class AnilistApi(
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { response ->
|
||||
val data = response["data"]!!.jsonObject
|
||||
val page = data["Page"]!!.jsonObject
|
||||
val media = page["mediaList"]!!.jsonArray
|
||||
val entries = media.map { jsonToALUserManga(it.jsonObject) }
|
||||
entries.firstOrNull()?.toTrack()
|
||||
}
|
||||
.parseAs<ALUserListMangaQueryResult>()
|
||||
.data.page.mediaList
|
||||
.map { it.toALUserManga() }
|
||||
.firstOrNull()
|
||||
?.toTrack()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getLibManga(
|
||||
track: Track,
|
||||
userid: Int,
|
||||
): Track = findLibManga(track, userid) ?: throw Exception("Could not find manga")
|
||||
userId: Int,
|
||||
): Track = findLibManga(track, userId) ?: throw Exception("Could not find manga")
|
||||
|
||||
fun createOAuth(token: String): OAuth =
|
||||
OAuth(token, "Bearer", System.currentTimeMillis() + 365.days.inWholeMilliseconds, 365.days.inWholeMilliseconds)
|
||||
fun createOAuth(token: String): ALOAuth = ALOAuth(token, "Bearer", System.currentTimeMillis() + 31536000000, 31536000000)
|
||||
|
||||
suspend fun getCurrentUser(): Pair<Int, String> =
|
||||
withIOContext {
|
||||
@@ -306,57 +323,14 @@ class AnilistApi(
|
||||
body = payload.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<ALCurrentUserResult>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonObject
|
||||
val viewer = data["Viewer"]!!.jsonObject
|
||||
Pair(
|
||||
viewer["id"]!!.jsonPrimitive.int,
|
||||
viewer["mediaListOptions"]!!.jsonObject["scoreFormat"]!!.jsonPrimitive.content,
|
||||
)
|
||||
val viewer = it.data.viewer
|
||||
Pair(viewer.id, viewer.mediaListOptions.scoreFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun jsonToALManga(struct: JsonObject): ALManga =
|
||||
ALManga(
|
||||
struct["id"]!!.jsonPrimitive.long,
|
||||
struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content,
|
||||
struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content,
|
||||
struct["description"]!!.jsonPrimitive.contentOrNull,
|
||||
struct["format"]!!.jsonPrimitive.content.replace("_", "-"),
|
||||
struct["status"]!!.jsonPrimitive.contentOrNull ?: "",
|
||||
parseDate(struct, "startDate"),
|
||||
struct["chapters"]!!.jsonPrimitive.intOrNull ?: 0,
|
||||
)
|
||||
|
||||
private fun jsonToALUserManga(struct: JsonObject): ALUserManga =
|
||||
ALUserManga(
|
||||
struct["id"]!!.jsonPrimitive.long,
|
||||
struct["status"]!!.jsonPrimitive.content,
|
||||
struct["scoreRaw"]!!.jsonPrimitive.int,
|
||||
struct["progress"]!!.jsonPrimitive.int,
|
||||
parseDate(struct, "startedAt"),
|
||||
parseDate(struct, "completedAt"),
|
||||
jsonToALManga(struct["media"]!!.jsonObject),
|
||||
)
|
||||
|
||||
private fun parseDate(
|
||||
struct: JsonObject,
|
||||
dateKey: String,
|
||||
): Long =
|
||||
try {
|
||||
val date = Calendar.getInstance()
|
||||
date.set(
|
||||
struct[dateKey]!!.jsonObject["year"]!!.jsonPrimitive.int,
|
||||
struct[dateKey]!!.jsonObject["month"]!!.jsonPrimitive.int - 1,
|
||||
struct[dateKey]!!.jsonObject["day"]!!.jsonPrimitive.int,
|
||||
)
|
||||
date.timeInMillis
|
||||
} catch (_: Exception) {
|
||||
0L
|
||||
}
|
||||
|
||||
private fun createDate(dateValue: Long): JsonObject {
|
||||
if (dateValue == 0L) {
|
||||
return buildJsonObject {
|
||||
@@ -366,12 +340,11 @@ class AnilistApi(
|
||||
}
|
||||
}
|
||||
|
||||
val calendar = Calendar.getInstance()
|
||||
calendar.timeInMillis = dateValue
|
||||
val dateTime = ZonedDateTime.ofInstant(Instant.ofEpochMilli(dateValue), ZoneId.systemDefault())
|
||||
return buildJsonObject {
|
||||
put("year", calendar.get(Calendar.YEAR))
|
||||
put("month", calendar.get(Calendar.MONTH) + 1)
|
||||
put("day", calendar.get(Calendar.DAY_OF_MONTH))
|
||||
put("year", dateTime.year)
|
||||
put("month", dateTime.monthValue)
|
||||
put("day", dateTime.dayOfMonth)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ package suwayomi.tachidesk.manga.impl.track.tracker.anilist
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.ALOAuth
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto.isExpired
|
||||
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||
import java.io.IOException
|
||||
|
||||
class AnilistInterceptor(
|
||||
private val anilist: Anilist,
|
||||
val anilist: Anilist,
|
||||
) : Interceptor {
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
@@ -14,7 +17,7 @@ class AnilistInterceptor(
|
||||
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
|
||||
* before its original expiration date.
|
||||
*/
|
||||
private var oauth: OAuth? = null
|
||||
private var oauth: ALOAuth? = null
|
||||
set(value) {
|
||||
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
|
||||
}
|
||||
@@ -44,7 +47,8 @@ class AnilistInterceptor(
|
||||
val authRequest =
|
||||
originalRequest
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
|
||||
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})")
|
||||
.build()
|
||||
|
||||
return chain.proceed(authRequest)
|
||||
@@ -54,7 +58,7 @@ class AnilistInterceptor(
|
||||
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
|
||||
* and the oauth object.
|
||||
*/
|
||||
fun setAuth(oauth: OAuth?) {
|
||||
fun setAuth(oauth: ALOAuth?) {
|
||||
this.oauth = oauth
|
||||
anilist.saveOAuth(oauth)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
|
||||
import android.annotation.StringRes
|
||||
import kotlinx.serialization.json.Json
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMOAuth
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.extractToken
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
@@ -24,14 +25,14 @@ class Bangumi(
|
||||
.map(Int::toString)
|
||||
}
|
||||
|
||||
override val supportsTrackDeletion: Boolean = false
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val interceptor by lazy { BangumiInterceptor(this) }
|
||||
|
||||
private val api by lazy { BangumiApi(id, client, interceptor) }
|
||||
|
||||
override val supportsPrivateTracking: Boolean = true
|
||||
|
||||
override fun getScoreList(): List<String> = SCORE_LIST
|
||||
|
||||
override fun displayScore(track: Track): String = track.score.toInt().toString()
|
||||
@@ -61,7 +62,7 @@ class Bangumi(
|
||||
): Track {
|
||||
val statusTrack = api.statusLibManga(track, getUsername())
|
||||
return if (statusTrack != null) {
|
||||
track.copyPersonalFrom(statusTrack)
|
||||
track.copyPersonalFrom(statusTrack, copyRemotePrivate = false)
|
||||
track.library_id = statusTrack.library_id
|
||||
track.score = statusTrack.score
|
||||
track.last_chapter_read = statusTrack.last_chapter_read
|
||||
@@ -74,7 +75,7 @@ class Bangumi(
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.status = if (hasReadChapters) READING else PLAN_TO_READ
|
||||
track.score = 0.0F
|
||||
track.score = 0.0
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
@@ -151,13 +152,3 @@ class Bangumi(
|
||||
interceptor.newAuth(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun Track.toApiStatus() =
|
||||
when (status) {
|
||||
Bangumi.PLAN_TO_READ -> 1
|
||||
Bangumi.COMPLETED -> 2
|
||||
Bangumi.READING -> 3
|
||||
Bangumi.ON_HOLD -> 4
|
||||
Bangumi.DROPPED -> 5
|
||||
else -> throw NotImplementedError("Unknown status: $status")
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ import okhttp3.Headers.Companion.headersOf
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMCollectionResponse
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMOAuth
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMSearchResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMUser
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
@@ -35,27 +39,33 @@ class BangumiApi(
|
||||
|
||||
suspend fun addLibManga(track: Track): Track =
|
||||
withIOContext {
|
||||
val url = "$API_URL/v0/users/-/collections/${track.media_id}"
|
||||
val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("type", track.toApiStatus())
|
||||
put("rate", track.score.toInt().coerceIn(0, 10))
|
||||
put("ep_status", track.last_chapter_read.toInt())
|
||||
}.toString().toRequestBody()
|
||||
put("private", track.private)
|
||||
}.toString()
|
||||
.toRequestBody()
|
||||
// Returns with 202 Accepted on success with no body
|
||||
authClient.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON))).awaitSuccess()
|
||||
authClient
|
||||
.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
|
||||
.awaitSuccess()
|
||||
track
|
||||
}
|
||||
|
||||
suspend fun updateLibManga(track: Track): Track =
|
||||
withIOContext {
|
||||
val url = "$API_URL/v0/users/-/collections/${track.media_id}"
|
||||
val url = "$API_URL/v0/users/-/collections/${track.remote_id}"
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("type", track.toApiStatus())
|
||||
put("rate", track.score.toInt().coerceIn(0, 10))
|
||||
put("ep_status", track.last_chapter_read.toInt())
|
||||
}.toString().toRequestBody()
|
||||
put("private", track.private)
|
||||
}.toString()
|
||||
.toRequestBody()
|
||||
|
||||
val request =
|
||||
Request
|
||||
@@ -65,7 +75,9 @@ class BangumiApi(
|
||||
.headers(headersOf("Content-Type", APP_JSON))
|
||||
.build()
|
||||
// Returns with 204 No Content
|
||||
authClient.newCall(request).awaitSuccess()
|
||||
authClient
|
||||
.newCall(request)
|
||||
.awaitSuccess()
|
||||
|
||||
track
|
||||
}
|
||||
@@ -86,7 +98,8 @@ class BangumiApi(
|
||||
add(1) // "Book" (书籍) type
|
||||
}
|
||||
}
|
||||
}.toString().toRequestBody()
|
||||
}.toString()
|
||||
.toRequestBody()
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
|
||||
@@ -104,7 +117,7 @@ class BangumiApi(
|
||||
username: String,
|
||||
): Track? =
|
||||
withIOContext {
|
||||
val url = "$API_URL/v0/users/$username/collections/${track.media_id}"
|
||||
val url = "$API_URL/v0/users/$username/collections/${track.remote_id}"
|
||||
with(json) {
|
||||
try {
|
||||
authClient
|
||||
@@ -113,8 +126,8 @@ class BangumiApi(
|
||||
.parseAs<BGMCollectionResponse>()
|
||||
.let {
|
||||
track.status = it.getStatus()
|
||||
track.last_chapter_read = it.epStatus?.toFloat() ?: 0.0F
|
||||
track.score = it.rate?.toFloat() ?: 0.0F
|
||||
track.last_chapter_read = it.epStatus?.toDouble() ?: 0.0
|
||||
track.score = it.rate?.toDouble() ?: 0.0
|
||||
track.total_chapters = it.subject?.eps ?: 0
|
||||
track
|
||||
}
|
||||
@@ -140,7 +153,10 @@ class BangumiApi(
|
||||
.add("redirect_uri", REDIRECT_URL)
|
||||
.build()
|
||||
with(json) {
|
||||
client.newCall(POST(OAUTH_URL, body = body)).awaitSuccess().parseAs<BGMOAuth>()
|
||||
client
|
||||
.newCall(POST(OAUTH_URL, body = body))
|
||||
.awaitSuccess()
|
||||
.parseAs<BGMOAuth>()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.BGMOAuth
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto.isExpired
|
||||
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
@@ -35,7 +37,7 @@ class BangumiInterceptor(
|
||||
.newBuilder()
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Suwayomi/Suwayomi-Server/v${BuildConfig.VERSION} (${BuildConfig.GITHUB})",
|
||||
"Suwayomi/Suwayomi-Server/${BuildConfig.VERSION} (${BuildConfig.GITHUB})",
|
||||
).apply {
|
||||
addHeader("Authorization", "Bearer ${currAuth.accessToken}")
|
||||
}.build()
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
|
||||
|
||||
import android.annotation.StringRes
|
||||
import kotlinx.serialization.encodeToString
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
|
||||
import kotlinx.serialization.json.Json
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
@@ -13,7 +13,7 @@ import java.text.DecimalFormat
|
||||
class Kitsu(
|
||||
id: Int,
|
||||
) : Tracker(id, "Kitsu"),
|
||||
DeletableTrackService {
|
||||
DeletableTracker {
|
||||
companion object {
|
||||
const val READING = 1
|
||||
const val COMPLETED = 2
|
||||
@@ -22,10 +22,10 @@ class Kitsu(
|
||||
const val PLAN_TO_READ = 5
|
||||
}
|
||||
|
||||
override val supportsTrackDeletion: Boolean = true
|
||||
|
||||
override val supportsReadingDates: Boolean = true
|
||||
|
||||
override val supportsPrivateTracking: Boolean = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val interceptor by lazy { KitsuInterceptor(this) }
|
||||
@@ -58,7 +58,7 @@ class Kitsu(
|
||||
return listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }
|
||||
}
|
||||
|
||||
override fun indexToScore(index: Int): Float = if (index > 0) (index + 1) / 2.0f else 0.0f
|
||||
override fun indexToScore(index: Int): Double = if (index > 0) (index + 1) / 2.0 else 0.0
|
||||
|
||||
override fun displayScore(track: Track): String {
|
||||
val df = DecimalFormat("0.#")
|
||||
@@ -78,7 +78,7 @@ class Kitsu(
|
||||
track.finished_reading_date = System.currentTimeMillis()
|
||||
} else {
|
||||
track.status = READING
|
||||
if (track.last_chapter_read == 1.0f) {
|
||||
if (track.last_chapter_read == 1.0) {
|
||||
track.started_reading_date = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
@@ -98,8 +98,8 @@ class Kitsu(
|
||||
): Track {
|
||||
val remoteTrack = api.findLibManga(track, getUserId())
|
||||
return if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.media_id = remoteTrack.media_id
|
||||
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
|
||||
track.remote_id = remoteTrack.remote_id
|
||||
|
||||
if (track.status != COMPLETED) {
|
||||
track.status = if (hasReadChapters) READING else track.status
|
||||
@@ -108,7 +108,7 @@ class Kitsu(
|
||||
update(track)
|
||||
} else {
|
||||
track.status = if (hasReadChapters) READING else PLAN_TO_READ
|
||||
track.score = 0.0f
|
||||
track.score = 0.0
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
@@ -140,14 +140,14 @@ class Kitsu(
|
||||
private fun getUserId(): String = getPassword()
|
||||
|
||||
// TODO: this seems to be called saveOAuth in other trackers
|
||||
fun saveToken(oauth: OAuth?) {
|
||||
fun saveToken(oauth: KitsuOAuth?) {
|
||||
trackPreferences.setTrackToken(this, json.encodeToString(oauth))
|
||||
}
|
||||
|
||||
// TODO: this seems to be called loadOAuth in other trackers
|
||||
fun restoreToken(): OAuth? =
|
||||
fun restoreToken(): KitsuOAuth? =
|
||||
try {
|
||||
json.decodeFromString<OAuth>(trackPreferences.getTrackToken(this)!!)
|
||||
json.decodeFromString<KitsuOAuth>(trackPreferences.getTrackToken(this)!!)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
|
||||
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
|
||||
import eu.kanade.tachiyomi.network.DELETE
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
@@ -9,12 +10,7 @@ import eu.kanade.tachiyomi.network.jsonMime
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.FormBody
|
||||
@@ -24,6 +20,11 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuAddMangaResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuAlgoliaSearchResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuCurrentUserResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuListSearchResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto.KitsuSearchResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
@@ -48,8 +49,9 @@ class KitsuApi(
|
||||
putJsonObject("data") {
|
||||
put("type", "libraryEntries")
|
||||
putJsonObject("attributes") {
|
||||
put("status", track.toKitsuStatus())
|
||||
put("status", track.toApiStatus())
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("private", track.private)
|
||||
}
|
||||
putJsonObject("relationships") {
|
||||
putJsonObject("user") {
|
||||
@@ -60,7 +62,7 @@ class KitsuApi(
|
||||
}
|
||||
putJsonObject("media") {
|
||||
putJsonObject("data") {
|
||||
put("id", track.media_id)
|
||||
put("id", track.remote_id)
|
||||
put("type", "manga")
|
||||
}
|
||||
}
|
||||
@@ -73,20 +75,13 @@ class KitsuApi(
|
||||
.newCall(
|
||||
POST(
|
||||
"${BASE_URL}library-entries",
|
||||
headers =
|
||||
headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
body =
|
||||
data
|
||||
.toString()
|
||||
.toRequestBody("application/vnd.api+json".toMediaType()),
|
||||
headers = headersOf("Content-Type", VND_API_JSON),
|
||||
body = data.toString().toRequestBody(VND_JSON_MEDIA_TYPE),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuAddMangaResult>()
|
||||
.let {
|
||||
track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long
|
||||
track.remote_id = it.data.id
|
||||
track
|
||||
}
|
||||
}
|
||||
@@ -98,37 +93,30 @@ class KitsuApi(
|
||||
buildJsonObject {
|
||||
putJsonObject("data") {
|
||||
put("type", "libraryEntries")
|
||||
put("id", track.media_id)
|
||||
put("id", track.remote_id)
|
||||
putJsonObject("attributes") {
|
||||
put("status", track.toKitsuStatus())
|
||||
put("status", track.toApiStatus())
|
||||
put("progress", track.last_chapter_read.toInt())
|
||||
put("ratingTwenty", track.toKitsuScore())
|
||||
put("ratingTwenty", track.toApiScore())
|
||||
put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
|
||||
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
|
||||
put("private", track.private)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url("${BASE_URL}library-entries/${track.media_id}")
|
||||
.headers(
|
||||
headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
).patch(
|
||||
data.toString().toRequestBody("application/vnd.api+json".toMediaType()),
|
||||
).build(),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
track
|
||||
}
|
||||
}
|
||||
authClient
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url("${BASE_URL}library-entries/${track.remote_id}")
|
||||
.headers(
|
||||
headersOf("Content-Type", VND_API_JSON),
|
||||
).patch(data.toString().toRequestBody(VND_JSON_MEDIA_TYPE))
|
||||
.build(),
|
||||
).awaitSuccess()
|
||||
|
||||
track
|
||||
}
|
||||
|
||||
suspend fun removeLibManga(track: Track) {
|
||||
@@ -136,12 +124,8 @@ class KitsuApi(
|
||||
authClient
|
||||
.newCall(
|
||||
DELETE(
|
||||
"${BASE_URL}library-entries/${track.media_id}",
|
||||
headers =
|
||||
headersOf(
|
||||
"Content-Type",
|
||||
"application/vnd.api+json",
|
||||
),
|
||||
"${BASE_URL}library-entries/${track.remote_id}",
|
||||
headers = headersOf("Content-Type", VND_API_JSON),
|
||||
),
|
||||
).awaitSuccess()
|
||||
}
|
||||
@@ -153,10 +137,9 @@ class KitsuApi(
|
||||
authClient
|
||||
.newCall(GET(ALGOLIA_KEY_URL))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuSearchResult>()
|
||||
.let {
|
||||
val key = it["media"]!!.jsonObject["key"]!!.jsonPrimitive.content
|
||||
algoliaSearch(key, query)
|
||||
algoliaSearch(it.media.key, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,14 +169,10 @@ class KitsuApi(
|
||||
body = jsonObject.toString().toRequestBody(jsonMime),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["hits"]!!
|
||||
.jsonArray
|
||||
.map { KitsuSearchManga(it.jsonObject) }
|
||||
.filter { it.subType != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
.parseAs<KitsuAlgoliaSearchResult>()
|
||||
.hits
|
||||
.filter { it.subtype != "novel" }
|
||||
.map { it.toTrack() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,19 +185,17 @@ class KitsuApi(
|
||||
"${BASE_URL}library-entries"
|
||||
.toUri()
|
||||
.buildUpon()
|
||||
.encodedQuery("filter[manga_id]=${track.media_id}&filter[user_id]=$userId")
|
||||
.encodedQuery("filter[manga_id]=${track.remote_id}&filter[user_id]=$userId")
|
||||
.appendQueryParameter("include", "manga")
|
||||
.build()
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuListSearchResult>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonArray
|
||||
if (data.size > 0) {
|
||||
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
|
||||
it.firstToTrack()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -232,19 +209,17 @@ class KitsuApi(
|
||||
"${BASE_URL}library-entries"
|
||||
.toUri()
|
||||
.buildUpon()
|
||||
.encodedQuery("filter[id]=${track.media_id}")
|
||||
.encodedQuery("filter[id]=${track.remote_id}")
|
||||
.appendQueryParameter("include", "manga")
|
||||
.build()
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<KitsuListSearchResult>()
|
||||
.let {
|
||||
val data = it["data"]!!.jsonArray
|
||||
if (data.size > 0) {
|
||||
val manga = it["included"]!!.jsonArray[0].jsonObject
|
||||
KitsuLibManga(data[0].jsonObject, manga).toTrack()
|
||||
if (it.data.isNotEmpty() && it.included.isNotEmpty()) {
|
||||
it.firstToTrack()
|
||||
} else {
|
||||
throw Exception("Could not find manga")
|
||||
}
|
||||
@@ -255,7 +230,7 @@ class KitsuApi(
|
||||
suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
): OAuth =
|
||||
): KitsuOAuth =
|
||||
withIOContext {
|
||||
val formBody: RequestBody =
|
||||
FormBody
|
||||
@@ -286,13 +261,9 @@ class KitsuApi(
|
||||
authClient
|
||||
.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!
|
||||
.jsonArray[0]
|
||||
.jsonObject["id"]!!
|
||||
.jsonPrimitive.content
|
||||
}
|
||||
.parseAs<KitsuCurrentUserResult>()
|
||||
.data[0]
|
||||
.id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +283,9 @@ class KitsuApi(
|
||||
"%5B%22synopsis%22%2C%22averageRating%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22" +
|
||||
"posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D"
|
||||
|
||||
private const val VND_API_JSON = "application/vnd.api+json"
|
||||
private val VND_JSON_MEDIA_TYPE = VND_API_JSON.toMediaType()
|
||||
|
||||
fun mangaUrl(remoteId: Long): String = BASE_MANGA_URL + remoteId
|
||||
|
||||
fun refreshTokenRequest(token: String) =
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
|
||||
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.dto.isExpired
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
@@ -14,14 +16,14 @@ class KitsuInterceptor(
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
*/
|
||||
private var oauth: OAuth? = kitsu.restoreToken()
|
||||
private var oauth: KitsuOAuth? = kitsu.restoreToken()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
val currAuth = oauth ?: throw Exception("Not authenticated with Kitsu")
|
||||
|
||||
val refreshToken = currAuth.refresh_token!!
|
||||
val refreshToken = currAuth.refreshToken!!
|
||||
|
||||
// Refresh access token if expired.
|
||||
if (currAuth.isExpired()) {
|
||||
@@ -37,7 +39,7 @@ class KitsuInterceptor(
|
||||
val authRequest =
|
||||
originalRequest
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
|
||||
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})")
|
||||
.header("Accept", "application/vnd.api+json")
|
||||
.header("Content-Type", "application/vnd.api+json")
|
||||
@@ -46,7 +48,7 @@ class KitsuInterceptor(
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
fun newAuth(oauth: OAuth?) {
|
||||
fun newAuth(oauth: KitsuOAuth?) {
|
||||
this.oauth = oauth
|
||||
kitsu.saveToken(oauth)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class KitsuSearchItemCover(
|
||||
val original: String?,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -1,9 +1,9 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates
|
||||
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.ListItem
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Rating
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUListItem
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MURating
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.copyTo
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.toTrackSearch
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
@@ -12,7 +12,7 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
class MangaUpdates(
|
||||
id: Int,
|
||||
) : Tracker(id, "MangaUpdates"),
|
||||
DeletableTrackService {
|
||||
DeletableTracker {
|
||||
companion object {
|
||||
const val READING_LIST = 0
|
||||
const val WISH_LIST = 1
|
||||
@@ -34,8 +34,6 @@ class MangaUpdates(
|
||||
}
|
||||
}
|
||||
|
||||
override val supportsTrackDeletion: Boolean = true
|
||||
|
||||
private val interceptor by lazy { MangaUpdatesInterceptor(this) }
|
||||
|
||||
private val api by lazy { MangaUpdatesApi(interceptor, client) }
|
||||
@@ -62,7 +60,7 @@ class MangaUpdates(
|
||||
|
||||
override fun getScoreList(): List<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()
|
||||
|
||||
@@ -88,8 +86,8 @@ class MangaUpdates(
|
||||
try {
|
||||
val (series, rating) = api.getSeriesListItem(track)
|
||||
track.copyFrom(series, rating)
|
||||
} catch (e: Exception) {
|
||||
track.score = 0f
|
||||
} catch (_: Exception) {
|
||||
track.score = 0.0
|
||||
api.addSeriesToList(track, hasReadChapters)
|
||||
track
|
||||
}
|
||||
@@ -107,12 +105,12 @@ class MangaUpdates(
|
||||
}
|
||||
|
||||
private fun Track.copyFrom(
|
||||
item: ListItem,
|
||||
rating: Rating?,
|
||||
item: MUListItem,
|
||||
rating: MURating?,
|
||||
): Track =
|
||||
apply {
|
||||
item.copyTo(this)
|
||||
score = rating?.rating ?: 0f
|
||||
score = rating?.rating ?: 0.0
|
||||
}
|
||||
|
||||
override suspend fun login(
|
||||
@@ -124,5 +122,5 @@ class MangaUpdates(
|
||||
interceptor.newAuth(authenticated.sessionToken)
|
||||
}
|
||||
|
||||
fun restoreSession(): String? = trackPreferences.getTrackPassword(this)
|
||||
fun restoreSession(): String? = trackPreferences.getTrackPassword(this)?.ifBlank { null }
|
||||
}
|
||||
|
||||
@@ -7,14 +7,10 @@ import eu.kanade.tachiyomi.network.PUT
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
@@ -22,10 +18,12 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Companion.READING_LIST
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Companion.WISH_LIST
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Context
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.ListItem
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Rating
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.Record
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUContext
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUListItem
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MULoginResponse
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MURating
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MURecord
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.MUSearchResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
@@ -35,9 +33,6 @@ class MangaUpdatesApi(
|
||||
) {
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val baseUrl = "https://api.mangaupdates.com"
|
||||
private val contentType = "application/vnd.api+json".toMediaType()
|
||||
|
||||
private val authClient by lazy {
|
||||
client
|
||||
.newBuilder()
|
||||
@@ -45,13 +40,13 @@ class MangaUpdatesApi(
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> {
|
||||
suspend fun getSeriesListItem(track: Track): Pair<MUListItem, MURating?> {
|
||||
val listItem =
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(GET("$baseUrl/v1/lists/series/${track.media_id}"))
|
||||
.newCall(GET("$BASE_URL/v1/lists/series/${track.remote_id}"))
|
||||
.awaitSuccess()
|
||||
.parseAs<ListItem>()
|
||||
.parseAs<MUListItem>()
|
||||
}
|
||||
|
||||
val rating = getSeriesRating(track)
|
||||
@@ -68,7 +63,7 @@ class MangaUpdatesApi(
|
||||
buildJsonArray {
|
||||
addJsonObject {
|
||||
putJsonObject("series") {
|
||||
put("id", track.media_id)
|
||||
put("id", track.remote_id)
|
||||
}
|
||||
put("list_id", status)
|
||||
}
|
||||
@@ -76,14 +71,14 @@ class MangaUpdatesApi(
|
||||
authClient
|
||||
.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/lists/series",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.let {
|
||||
if (it.code == 200) {
|
||||
track.status = status
|
||||
track.last_chapter_read = 1f
|
||||
track.last_chapter_read = 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,7 +88,7 @@ class MangaUpdatesApi(
|
||||
buildJsonArray {
|
||||
addJsonObject {
|
||||
putJsonObject("series") {
|
||||
put("id", track.media_id)
|
||||
put("id", track.remote_id)
|
||||
}
|
||||
put("list_id", track.status)
|
||||
putJsonObject("status") {
|
||||
@@ -104,8 +99,8 @@ class MangaUpdatesApi(
|
||||
authClient
|
||||
.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series/update",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/lists/series/update",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
).awaitSuccess()
|
||||
|
||||
@@ -115,31 +110,32 @@ class MangaUpdatesApi(
|
||||
suspend fun deleteSeriesFromList(track: Track) {
|
||||
val body =
|
||||
buildJsonArray {
|
||||
add(track.media_id)
|
||||
add(track.remote_id)
|
||||
}
|
||||
authClient
|
||||
.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series/delete",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/lists/series/delete",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
).awaitSuccess()
|
||||
}
|
||||
|
||||
private suspend fun getSeriesRating(track: Track): Rating? =
|
||||
private suspend fun getSeriesRating(track: Track): MURating? =
|
||||
try {
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(GET("$baseUrl/v1/series/${track.media_id}/rating"))
|
||||
.newCall(GET("$BASE_URL/v1/series/${track.remote_id}/rating"))
|
||||
.awaitSuccess()
|
||||
.parseAs<Rating>()
|
||||
.parseAs<MURating>()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun updateSeriesRating(track: Track) {
|
||||
if (track.score != 0f) {
|
||||
if (track.score < 0.0) return
|
||||
if (track.score != 0.0) {
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("rating", track.score)
|
||||
@@ -147,21 +143,19 @@ class MangaUpdatesApi(
|
||||
authClient
|
||||
.newCall(
|
||||
PUT(
|
||||
url = "$baseUrl/v1/series/${track.media_id}/rating",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/series/${track.remote_id}/rating",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
).awaitSuccess()
|
||||
} else {
|
||||
authClient
|
||||
.newCall(
|
||||
DELETE(
|
||||
url = "$baseUrl/v1/series/${track.media_id}/rating",
|
||||
),
|
||||
DELETE(url = "$BASE_URL/v1/series/${track.remote_id}/rating"),
|
||||
).awaitSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String): List<Record> {
|
||||
suspend fun search(query: String): List<MURecord> {
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("search", query)
|
||||
@@ -173,27 +167,25 @@ class MangaUpdatesApi(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return with(json) {
|
||||
client
|
||||
.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/series/search",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/series/search",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
obj["results"]?.jsonArray?.map { element ->
|
||||
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
|
||||
}
|
||||
}.orEmpty()
|
||||
.parseAs<MUSearchResult>()
|
||||
.results
|
||||
.map { it.record }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authenticate(
|
||||
username: String,
|
||||
password: String,
|
||||
): Context? {
|
||||
): MUContext? {
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("username", username)
|
||||
@@ -203,19 +195,18 @@ class MangaUpdatesApi(
|
||||
client
|
||||
.newCall(
|
||||
PUT(
|
||||
url = "$baseUrl/v1/account/login",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
url = "$BASE_URL/v1/account/login",
|
||||
body = body.toString().toRequestBody(CONTENT_TYPE),
|
||||
),
|
||||
).awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
try {
|
||||
json.decodeFromJsonElement<Context>(obj["context"]!!)
|
||||
} catch (e: Exception) {
|
||||
// logcat(LogPriority.ERROR, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
.parseAs<MULoginResponse>()
|
||||
.context
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val BASE_URL = "https://api.mangaupdates.com"
|
||||
|
||||
private val CONTENT_TYPE = "application/vnd.api+json".toMediaType()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class MangaUpdatesInterceptor(
|
||||
originalRequest
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION}")
|
||||
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})")
|
||||
.build()
|
||||
|
||||
return chain.proceed(authRequest)
|
||||
|
||||
@@ -4,7 +4,7 @@ import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Context(
|
||||
data class MUContext(
|
||||
@SerialName("session_token")
|
||||
val sessionToken: String,
|
||||
val uid: Long,
|
||||
@@ -3,8 +3,8 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Image(
|
||||
val url: Url? = null,
|
||||
data class MUImage(
|
||||
val url: MUUrl? = null,
|
||||
val height: Int? = null,
|
||||
val width: Int? = null,
|
||||
)
|
||||
@@ -6,16 +6,16 @@ import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Com
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
|
||||
@Serializable
|
||||
data class ListItem(
|
||||
val series: Series? = null,
|
||||
data class MUListItem(
|
||||
val series: MUSeries? = null,
|
||||
@SerialName("list_id")
|
||||
val listId: Int? = null,
|
||||
val status: Status? = null,
|
||||
val status: MUStatus? = null,
|
||||
val priority: Int? = null,
|
||||
)
|
||||
|
||||
fun ListItem.copyTo(track: Track): Track =
|
||||
fun MUListItem.copyTo(track: Track): Track =
|
||||
track.apply {
|
||||
this.status = listId ?: READING_LIST
|
||||
this.last_chapter_read = this@copyTo.status?.chapter?.toFloat() ?: 0f
|
||||
this.last_chapter_read = this@copyTo.status?.chapter?.toDouble() ?: 0.0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MULoginResponse(
|
||||
val context: MUContext,
|
||||
)
|
||||
@@ -4,11 +4,11 @@ import kotlinx.serialization.Serializable
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
|
||||
@Serializable
|
||||
data class Rating(
|
||||
val rating: Float? = null,
|
||||
data class MURating(
|
||||
val rating: Double? = null,
|
||||
)
|
||||
|
||||
fun Rating.copyTo(track: Track): Track =
|
||||
fun MURating.copyTo(track: Track): Track =
|
||||
track.apply {
|
||||
this.score = rating ?: 0f
|
||||
this.score = rating ?: 0.0
|
||||
}
|
||||
@@ -2,17 +2,17 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import suwayomi.tachidesk.manga.impl.track.Track.htmlDecode
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
|
||||
@Serializable
|
||||
data class Record(
|
||||
data class MURecord(
|
||||
@SerialName("series_id")
|
||||
val seriesId: Long? = null,
|
||||
val title: String? = null,
|
||||
val url: String? = null,
|
||||
val description: String? = null,
|
||||
val image: Image? = null,
|
||||
val image: MUImage? = null,
|
||||
val type: String? = null,
|
||||
val year: String? = null,
|
||||
@SerialName("bayesian_rating")
|
||||
@@ -23,11 +23,9 @@ data class Record(
|
||||
val latestChapter: Int? = null,
|
||||
)
|
||||
|
||||
private fun String.htmlDecode(): String = Jsoup.parse(this).wholeText()
|
||||
|
||||
fun Record.toTrackSearch(id: Int): TrackSearch =
|
||||
fun MURecord.toTrackSearch(id: Int): TrackSearch =
|
||||
TrackSearch.create(id).apply {
|
||||
media_id = this@toTrackSearch.seriesId ?: 0L
|
||||
remote_id = this@toTrackSearch.seriesId ?: 0L
|
||||
title = this@toTrackSearch.title?.htmlDecode() ?: ""
|
||||
total_chapters = 0
|
||||
cover_url = this@toTrackSearch.image?.url?.original ?: ""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Series(
|
||||
data class MUSeries(
|
||||
val id: Long? = null,
|
||||
val title: String? = null,
|
||||
)
|
||||
@@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Status(
|
||||
data class MUStatus(
|
||||
val volume: Int? = null,
|
||||
val chapter: Int? = null,
|
||||
)
|
||||
@@ -3,7 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Url(
|
||||
data class MUUrl(
|
||||
val original: String? = null,
|
||||
val thumb: String? = null,
|
||||
)
|
||||
@@ -9,19 +9,19 @@ interface Track : Serializable {
|
||||
|
||||
var manga_id: Int
|
||||
|
||||
var sync_id: Int
|
||||
var tracker_id: Int
|
||||
|
||||
var media_id: Long
|
||||
var remote_id: Long
|
||||
|
||||
var library_id: Long?
|
||||
|
||||
var title: String
|
||||
|
||||
var last_chapter_read: Float
|
||||
var last_chapter_read: Double
|
||||
|
||||
var total_chapters: Int
|
||||
|
||||
var score: Float
|
||||
var score: Double
|
||||
|
||||
var status: Int
|
||||
|
||||
@@ -31,18 +31,24 @@ interface Track : Serializable {
|
||||
|
||||
var tracking_url: String
|
||||
|
||||
fun copyPersonalFrom(other: Track) {
|
||||
var private: Boolean
|
||||
|
||||
fun copyPersonalFrom(
|
||||
other: Track,
|
||||
copyRemotePrivate: Boolean = true,
|
||||
) {
|
||||
last_chapter_read = other.last_chapter_read
|
||||
score = other.score
|
||||
status = other.status
|
||||
started_reading_date = other.started_reading_date
|
||||
finished_reading_date = other.finished_reading_date
|
||||
if (copyRemotePrivate) private = other.private
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(serviceId: Int): Track =
|
||||
TrackImpl().apply {
|
||||
sync_id = serviceId
|
||||
tracker_id = serviceId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.lastChapterRead
|
||||
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.remoteUrl
|
||||
import suwayomi.tachidesk.manga.model.table.TrackSearchTable
|
||||
|
||||
fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass =
|
||||
TrackRecordDataClass(
|
||||
@@ -22,62 +23,89 @@ fun ResultRow.toTrackRecordDataClass(): TrackRecordDataClass =
|
||||
remoteUrl = this[TrackRecordTable.remoteUrl],
|
||||
startDate = this[TrackRecordTable.startDate],
|
||||
finishDate = this[TrackRecordTable.finishDate],
|
||||
private = this[TrackRecordTable.private],
|
||||
)
|
||||
|
||||
fun ResultRow.toTrack(): Track =
|
||||
Track.create(this[TrackRecordTable.trackerId]).also {
|
||||
it.id = this[TrackRecordTable.id].value
|
||||
it.manga_id = this[TrackRecordTable.mangaId].value
|
||||
it.media_id = this[TrackRecordTable.remoteId]
|
||||
it.remote_id = this[TrackRecordTable.remoteId]
|
||||
it.library_id = this[TrackRecordTable.libraryId]
|
||||
it.title = this[TrackRecordTable.title]
|
||||
it.last_chapter_read = this[TrackRecordTable.lastChapterRead].toFloat()
|
||||
it.last_chapter_read = this[TrackRecordTable.lastChapterRead]
|
||||
it.total_chapters = this[TrackRecordTable.totalChapters]
|
||||
it.status = this[TrackRecordTable.status]
|
||||
it.score = this[TrackRecordTable.score].toFloat()
|
||||
it.score = this[TrackRecordTable.score]
|
||||
it.tracking_url = this[TrackRecordTable.remoteUrl]
|
||||
it.started_reading_date = this[TrackRecordTable.startDate]
|
||||
it.finished_reading_date = this[TrackRecordTable.finishDate]
|
||||
it.private = this[TrackRecordTable.private]
|
||||
}
|
||||
|
||||
fun ResultRow.toTrackSearch(): TrackSearch =
|
||||
TrackSearch.create(this[TrackSearchTable.trackerId]).also {
|
||||
it.id = this[TrackSearchTable.id].value
|
||||
it.remote_id = this[TrackSearchTable.remoteId]
|
||||
it.library_id = this[TrackSearchTable.libraryId]
|
||||
it.title = this[TrackSearchTable.title]
|
||||
it.last_chapter_read = this[TrackSearchTable.lastChapterRead]
|
||||
it.total_chapters = this[TrackSearchTable.totalChapters]
|
||||
it.status = this[TrackSearchTable.status]
|
||||
it.score = this[TrackSearchTable.score]
|
||||
it.tracking_url = this[TrackSearchTable.trackingUrl]
|
||||
it.started_reading_date = this[TrackSearchTable.startedReadingDate]
|
||||
it.finished_reading_date = this[TrackSearchTable.finishedReadingDate]
|
||||
it.private = this[TrackSearchTable.private]
|
||||
it.authors = this[TrackSearchTable.authors]?.split(",").orEmpty()
|
||||
it.artists = this[TrackSearchTable.artists]?.split(",").orEmpty()
|
||||
it.cover_url = this[TrackSearchTable.coverUrl]
|
||||
it.summary = this[TrackSearchTable.summary]
|
||||
it.publishing_status = this[TrackSearchTable.publishingStatus]
|
||||
it.publishing_type = this[TrackSearchTable.publishingType]
|
||||
it.start_date = this[TrackSearchTable.startDate]
|
||||
}
|
||||
|
||||
fun BackupTracking.toTrack(mangaId: Int): Track =
|
||||
Track.create(syncId).also {
|
||||
it.id = -1
|
||||
it.manga_id = mangaId
|
||||
it.media_id = mediaId
|
||||
it.remote_id = mediaId
|
||||
it.library_id = libraryId
|
||||
it.title = title
|
||||
it.last_chapter_read = lastChapterRead
|
||||
it.last_chapter_read = lastChapterRead.toDouble()
|
||||
it.total_chapters = totalChapters
|
||||
it.status = status
|
||||
it.score = score
|
||||
it.score = score.toDouble()
|
||||
it.tracking_url = trackingUrl
|
||||
it.started_reading_date = startedReadingDate
|
||||
it.finished_reading_date = finishedReadingDate
|
||||
it.private = private
|
||||
}
|
||||
|
||||
fun TrackRecordDataClass.toTrack(): Track =
|
||||
Track.create(trackerId).also {
|
||||
it.id = id
|
||||
it.manga_id = mangaId
|
||||
it.media_id = remoteId
|
||||
it.remote_id = remoteId
|
||||
it.library_id = libraryId
|
||||
it.title = title
|
||||
it.last_chapter_read = lastChapterRead.toFloat()
|
||||
it.last_chapter_read = lastChapterRead
|
||||
it.total_chapters = totalChapters
|
||||
it.status = status
|
||||
it.score = score.toFloat()
|
||||
it.score = score
|
||||
it.tracking_url = remoteUrl
|
||||
it.started_reading_date = startDate
|
||||
it.finished_reading_date = finishDate
|
||||
it.private = private
|
||||
}
|
||||
|
||||
fun Track.toTrackRecordDataClass(): TrackRecordDataClass =
|
||||
TrackRecordDataClass(
|
||||
id = id ?: -1,
|
||||
mangaId = manga_id,
|
||||
trackerId = sync_id,
|
||||
remoteId = media_id,
|
||||
trackerId = tracker_id,
|
||||
remoteId = remote_id,
|
||||
libraryId = library_id,
|
||||
title = title,
|
||||
lastChapterRead = last_chapter_read.toDouble(),
|
||||
@@ -87,4 +115,5 @@ fun Track.toTrackRecordDataClass(): TrackRecordDataClass =
|
||||
remoteUrl = tracking_url,
|
||||
startDate = started_reading_date,
|
||||
finishDate = finished_reading_date,
|
||||
private = private,
|
||||
)
|
||||
|
||||
@@ -7,19 +7,19 @@ class TrackImpl : Track {
|
||||
|
||||
override var manga_id: Int = 0
|
||||
|
||||
override var sync_id: Int = 0
|
||||
override var tracker_id: Int = 0
|
||||
|
||||
override var media_id: Long = 0
|
||||
override var remote_id: Long = 0
|
||||
|
||||
override var library_id: Long? = null
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var last_chapter_read: Float = 0F
|
||||
override var last_chapter_read: Double = 0.0
|
||||
|
||||
override var total_chapters: Int = 0
|
||||
|
||||
override var score: Float = 0f
|
||||
override var score: Double = 0.0
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
@@ -28,4 +28,6 @@ class TrackImpl : Track {
|
||||
override var finished_reading_date: Long = 0
|
||||
|
||||
override var tracking_url: String = ""
|
||||
|
||||
override var private: Boolean = false
|
||||
}
|
||||
|
||||
@@ -2,16 +2,38 @@
|
||||
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.model
|
||||
|
||||
class TrackSearch {
|
||||
var sync_id: Int = 0
|
||||
class TrackSearch : Track {
|
||||
override var id: Int? = null
|
||||
|
||||
var media_id: Long = 0
|
||||
override var manga_id: Int = 0
|
||||
|
||||
lateinit var title: String
|
||||
override var tracker_id: Int = 0
|
||||
|
||||
var total_chapters: Int = 0
|
||||
override var remote_id: Long = 0
|
||||
|
||||
lateinit var tracking_url: String
|
||||
override var library_id: Long? = null
|
||||
|
||||
override lateinit var title: String
|
||||
|
||||
override var last_chapter_read: Double = 0.0
|
||||
|
||||
override var total_chapters: Int = 0
|
||||
|
||||
override var score: Double = -1.0
|
||||
|
||||
override var status: Int = 0
|
||||
|
||||
override var started_reading_date: Long = 0
|
||||
|
||||
override var finished_reading_date: Long = 0
|
||||
|
||||
override var private: Boolean = false
|
||||
|
||||
override lateinit var tracking_url: String
|
||||
|
||||
var authors: List<String> = emptyList()
|
||||
|
||||
var artists: List<String> = emptyList()
|
||||
|
||||
var cover_url: String = ""
|
||||
|
||||
@@ -29,22 +51,24 @@ class TrackSearch {
|
||||
|
||||
other as TrackSearch
|
||||
|
||||
if (sync_id != other.sync_id) return false
|
||||
if (media_id != other.media_id) return false
|
||||
if (manga_id != other.manga_id) return false
|
||||
if (tracker_id != other.tracker_id) return false
|
||||
if (remote_id != other.remote_id) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = sync_id.hashCode()
|
||||
result = 31 * result + media_id.hashCode()
|
||||
var result = manga_id.hashCode()
|
||||
result = 31 * result + tracker_id.hashCode()
|
||||
result = 31 * result + remote_id.hashCode()
|
||||
return result
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(serviceId: Int): TrackSearch =
|
||||
TrackSearch().apply {
|
||||
sync_id = serviceId
|
||||
tracker_id = serviceId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist
|
||||
|
||||
import android.annotation.StringRes
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTrackService
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.DeletableTracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.Tracker
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.extractToken
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALOAuth
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
|
||||
class MyAnimeList(
|
||||
id: Int,
|
||||
) : Tracker(id, "MyAnimeList"),
|
||||
DeletableTrackService {
|
||||
DeletableTracker {
|
||||
companion object {
|
||||
const val READING = 1
|
||||
const val COMPLETED = 2
|
||||
@@ -28,8 +28,6 @@ class MyAnimeList(
|
||||
private const val SEARCH_LIST_PREFIX = "my:"
|
||||
}
|
||||
|
||||
override val supportsTrackDeletion: Boolean = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private val interceptor by lazy { MyAnimeListInterceptor(this) }
|
||||
@@ -78,7 +76,7 @@ class MyAnimeList(
|
||||
track.finished_reading_date = System.currentTimeMillis()
|
||||
} else if (track.status != REREADING) {
|
||||
track.status = READING
|
||||
if (track.last_chapter_read == 1F) {
|
||||
if (track.last_chapter_read == 1.0) {
|
||||
track.started_reading_date = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
@@ -99,18 +97,18 @@ class MyAnimeList(
|
||||
val remoteTrack = api.findListItem(track)
|
||||
return if (remoteTrack != null) {
|
||||
track.copyPersonalFrom(remoteTrack)
|
||||
track.media_id = remoteTrack.media_id
|
||||
track.remote_id = remoteTrack.remote_id
|
||||
|
||||
if (track.status != COMPLETED) {
|
||||
val isRereading = track.status == REREADING
|
||||
track.status = if (isRereading.not() && hasReadChapters) READING else track.status
|
||||
track.status = if (!isRereading && hasReadChapters) READING else track.status
|
||||
}
|
||||
|
||||
update(track)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
track.status = if (hasReadChapters) READING else PLAN_TO_READ
|
||||
track.score = 0F
|
||||
track.score = 0.0
|
||||
add(track)
|
||||
}
|
||||
}
|
||||
@@ -147,11 +145,10 @@ class MyAnimeList(
|
||||
|
||||
suspend fun login(authCode: String) {
|
||||
try {
|
||||
logger.debug { "login $authCode" }
|
||||
val oauth = api.getAccessToken(authCode)
|
||||
interceptor.setAuth(oauth)
|
||||
val username = api.getCurrentUser()
|
||||
saveCredentials(username, oauth.access_token)
|
||||
saveCredentials(username, oauth.accessToken)
|
||||
} catch (e: Throwable) {
|
||||
logger.error(e) { "oauth err" }
|
||||
logout()
|
||||
@@ -165,13 +162,13 @@ class MyAnimeList(
|
||||
interceptor.setAuth(null)
|
||||
}
|
||||
|
||||
fun saveOAuth(oAuth: OAuth?) {
|
||||
fun saveOAuth(oAuth: MALOAuth?) {
|
||||
trackPreferences.setTrackToken(this, json.encodeToString(oAuth))
|
||||
}
|
||||
|
||||
fun loadOAuth(): OAuth? =
|
||||
fun loadOAuth(): MALOAuth? =
|
||||
try {
|
||||
json.decodeFromString<OAuth>(trackPreferences.getTrackToken(this)!!)
|
||||
json.decodeFromString<MALOAuth>(trackPreferences.getTrackToken(this)!!)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "loadOAuth err" }
|
||||
null
|
||||
|
||||
@@ -2,6 +2,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import eu.kanade.tachiyomi.network.DELETE
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.awaitSuccess
|
||||
@@ -11,15 +12,6 @@ import eu.kanade.tachiyomi.util.lang.withIOContext
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.float
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -28,6 +20,13 @@ import okhttp3.RequestBody
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALListItem
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALListItemStatus
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALManga
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALOAuth
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALSearchResult
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALUser
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALUserSearchResult
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@@ -40,7 +39,7 @@ class MyAnimeListApi(
|
||||
|
||||
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
|
||||
|
||||
suspend fun getAccessToken(authCode: String): OAuth =
|
||||
suspend fun getAccessToken(authCode: String): MALOAuth =
|
||||
withIOContext {
|
||||
val formBody: RequestBody =
|
||||
FormBody
|
||||
@@ -70,8 +69,8 @@ class MyAnimeListApi(
|
||||
authClient
|
||||
.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { it["name"]!!.jsonPrimitive.content }
|
||||
.parseAs<MALUser>()
|
||||
.name
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,17 +88,11 @@ class MyAnimeListApi(
|
||||
authClient
|
||||
.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let {
|
||||
it["data"]!!
|
||||
.jsonArray
|
||||
.map { data -> data.jsonObject["node"]!!.jsonObject }
|
||||
.map { node ->
|
||||
val id = node["id"]!!.jsonPrimitive.int
|
||||
async { getMangaDetails(id) }
|
||||
}.awaitAll()
|
||||
.filter { trackSearch -> !trackSearch.publishing_type.contains("novel") }
|
||||
}
|
||||
.parseAs<MALSearchResult>()
|
||||
.data
|
||||
.map { async { getMangaDetails(it.node.id) } }
|
||||
.awaitAll()
|
||||
.filter { !it.publishing_type.contains("novel") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,33 +103,27 @@ class MyAnimeListApi(
|
||||
.toUri()
|
||||
.buildUpon()
|
||||
.appendPath(id.toString())
|
||||
.appendQueryParameter("fields", "id,title,synopsis,num_chapters,main_picture,status,media_type,start_date")
|
||||
.build()
|
||||
.appendQueryParameter(
|
||||
"fields",
|
||||
"id,title,synopsis,num_chapters,mean,main_picture,status,media_type,start_date",
|
||||
).build()
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(GET(url.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<MALManga>()
|
||||
.let {
|
||||
val obj = it.jsonObject
|
||||
TrackSearch.create(TrackerManager.MYANIMELIST).apply {
|
||||
media_id = obj["id"]!!.jsonPrimitive.long
|
||||
title = obj["title"]!!.jsonPrimitive.content
|
||||
summary = obj["synopsis"]?.jsonPrimitive?.content ?: ""
|
||||
total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
|
||||
cover_url =
|
||||
obj["main_picture"]
|
||||
?.jsonObject
|
||||
?.get("large")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?: ""
|
||||
tracking_url = "https://myanimelist.net/manga/$media_id"
|
||||
publishing_status =
|
||||
obj["status"]!!.jsonPrimitive.content.replace("_", " ")
|
||||
publishing_type =
|
||||
obj["media_type"]!!.jsonPrimitive.content.replace("_", " ")
|
||||
start_date = obj["start_date"]?.jsonPrimitive?.content ?: ""
|
||||
remote_id = it.id
|
||||
title = it.title
|
||||
summary = it.synopsis
|
||||
total_chapters = it.numChapters
|
||||
score = it.mean
|
||||
cover_url = it.covers?.large.orEmpty()
|
||||
tracking_url = "https://myanimelist.net/manga/$remote_id"
|
||||
publishing_status = it.status.replace("_", " ")
|
||||
publishing_type = it.mediaType.replace("_", " ")
|
||||
start_date = it.startDate ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,30 +148,25 @@ class MyAnimeListApi(
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(mangaUrl(track.media_id).toString())
|
||||
.url(mangaUrl(track.remote_id).toString())
|
||||
.put(formBodyBuilder.build())
|
||||
.build()
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(request)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.parseAs<MALListItemStatus>()
|
||||
.let { parseMangaItem(it, track) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteItem(track: Track) =
|
||||
suspend fun deleteItem(track: Track) {
|
||||
withIOContext {
|
||||
val request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(mangaUrl(track.media_id).toString())
|
||||
.delete()
|
||||
.build()
|
||||
authClient
|
||||
.newCall(request)
|
||||
.newCall(DELETE(mangaUrl(track.remote_id).toString()))
|
||||
.awaitSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun findListItem(track: Track): Track? =
|
||||
withIOContext {
|
||||
@@ -192,19 +174,17 @@ class MyAnimeListApi(
|
||||
"$BASE_API_URL/manga"
|
||||
.toUri()
|
||||
.buildUpon()
|
||||
.appendPath(track.media_id.toString())
|
||||
.appendPath(track.remote_id.toString())
|
||||
.appendQueryParameter("fields", "num_chapters,my_list_status{start_date,finish_date}")
|
||||
.build()
|
||||
with(json) {
|
||||
authClient
|
||||
.newCall(GET(uri.toString()))
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
track.total_chapters = obj["num_chapters"]!!.jsonPrimitive.int
|
||||
obj.jsonObject["my_list_status"]?.jsonObject?.let {
|
||||
parseMangaItem(it, track)
|
||||
}
|
||||
.parseAs<MALListItem>()
|
||||
.let { item ->
|
||||
track.total_chapters = item.numChapters
|
||||
item.myListStatus?.let { parseMangaItem(it, track) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,39 +194,23 @@ class MyAnimeListApi(
|
||||
offset: Int = 0,
|
||||
): List<TrackSearch> =
|
||||
withIOContext {
|
||||
val json = getListPage(offset)
|
||||
val obj = json.jsonObject
|
||||
val myListSearchResult = getListPage(offset)
|
||||
|
||||
val matches =
|
||||
obj["data"]!!
|
||||
.jsonArray
|
||||
.filter {
|
||||
it.jsonObject["node"]!!.jsonObject["title"]!!.jsonPrimitive.content.contains(
|
||||
query,
|
||||
ignoreCase = true,
|
||||
)
|
||||
}.map {
|
||||
val id =
|
||||
it.jsonObject["node"]!!
|
||||
.jsonObject["id"]!!
|
||||
.jsonPrimitive.int
|
||||
async { getMangaDetails(id) }
|
||||
}.awaitAll()
|
||||
myListSearchResult.data
|
||||
.filter { it.node.title.contains(query, ignoreCase = true) }
|
||||
.map { async { getMangaDetails(it.node.id) } }
|
||||
.awaitAll()
|
||||
|
||||
// Check next page if there's more
|
||||
if (!obj["paging"]!!
|
||||
.jsonObject["next"]
|
||||
?.jsonPrimitive
|
||||
?.contentOrNull
|
||||
.isNullOrBlank()
|
||||
) {
|
||||
if (!myListSearchResult.paging.next.isNullOrBlank()) {
|
||||
matches + findListItems(query, offset + LIST_PAGINATION_AMOUNT)
|
||||
} else {
|
||||
matches
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun getListPage(offset: Int): JsonObject =
|
||||
private suspend fun getListPage(offset: Int): MALUserSearchResult =
|
||||
withIOContext {
|
||||
val urlBuilder =
|
||||
"$BASE_API_URL/users/@me/mangalist"
|
||||
@@ -273,23 +237,17 @@ class MyAnimeListApi(
|
||||
}
|
||||
|
||||
private fun parseMangaItem(
|
||||
response: JsonObject,
|
||||
listStatus: MALListItemStatus,
|
||||
track: Track,
|
||||
): Track {
|
||||
val obj = response.jsonObject
|
||||
return track.apply {
|
||||
val isRereading = obj["is_rereading"]!!.jsonPrimitive.boolean
|
||||
status = if (isRereading) MyAnimeList.REREADING else getStatus(obj["status"]?.jsonPrimitive?.content)
|
||||
last_chapter_read = obj["num_chapters_read"]!!.jsonPrimitive.float
|
||||
score = obj["score"]!!.jsonPrimitive.int.toFloat()
|
||||
obj["start_date"]?.let {
|
||||
started_reading_date = parseDate(it.jsonPrimitive.content)
|
||||
}
|
||||
obj["finish_date"]?.let {
|
||||
finished_reading_date = parseDate(it.jsonPrimitive.content)
|
||||
}
|
||||
): Track =
|
||||
track.apply {
|
||||
val isRereading = listStatus.isRereading
|
||||
status = if (isRereading) MyAnimeList.REREADING else getStatus(listStatus.status)
|
||||
last_chapter_read = listStatus.numChaptersRead
|
||||
score = listStatus.score.toDouble()
|
||||
listStatus.startDate?.let { started_reading_date = parseDate(it) }
|
||||
listStatus.finishDate?.let { finished_reading_date = parseDate(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseDate(isoDate: String): Long = SimpleDateFormat("yyyy-MM-dd", Locale.US).parse(isoDate)?.time ?: 0L
|
||||
|
||||
@@ -332,12 +290,12 @@ class MyAnimeListApi(
|
||||
.appendPath("my_list_status")
|
||||
.build()
|
||||
|
||||
fun refreshTokenRequest(oauth: OAuth): Request {
|
||||
fun refreshTokenRequest(oauth: MALOAuth): Request {
|
||||
val formBody: RequestBody =
|
||||
FormBody
|
||||
.Builder()
|
||||
.add("client_id", CLIENT_ID)
|
||||
.add("refresh_token", oauth.refresh_token)
|
||||
.add("refresh_token", oauth.refreshToken)
|
||||
.add("grant_type", "refresh_token")
|
||||
.build()
|
||||
|
||||
@@ -347,7 +305,7 @@ class MyAnimeListApi(
|
||||
val headers =
|
||||
Headers
|
||||
.Builder()
|
||||
.add("Authorization", "Bearer ${oauth.access_token}")
|
||||
.add("Authorization", "Bearer ${oauth.accessToken}")
|
||||
.build()
|
||||
|
||||
return POST("$BASE_OAUTH_URL/token", body = formBody, headers = headers)
|
||||
|
||||
@@ -5,8 +5,7 @@ import eu.kanade.tachiyomi.network.parseAs
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.TokenRefreshFailed
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto.MALOAuth
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import java.io.IOException
|
||||
|
||||
@@ -15,11 +14,12 @@ class MyAnimeListInterceptor(
|
||||
) : Interceptor {
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
private var oauth: OAuth? = myanimelist.loadOAuth()
|
||||
private var oauth: MALOAuth? = myanimelist.loadOAuth()
|
||||
private val tokenExpired get() = myanimelist.getIfAuthExpired()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
if (myanimelist.getIfAuthExpired()) {
|
||||
throw TokenExpired()
|
||||
if (tokenExpired) {
|
||||
throw MALTokenExpired()
|
||||
}
|
||||
val originalRequest = chain.request()
|
||||
|
||||
@@ -35,7 +35,7 @@ class MyAnimeListInterceptor(
|
||||
val authRequest =
|
||||
originalRequest
|
||||
.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.accessToken}")
|
||||
.header("User-Agent", "Suwayomi v${AppInfo.getVersionName()}")
|
||||
.build()
|
||||
|
||||
@@ -46,37 +46,44 @@ class MyAnimeListInterceptor(
|
||||
* Called when the user authenticates with MyAnimeList for the first time. Sets the refresh token
|
||||
* and the oauth object.
|
||||
*/
|
||||
fun setAuth(oauth: OAuth?) {
|
||||
fun setAuth(oauth: MALOAuth?) {
|
||||
this.oauth = oauth
|
||||
myanimelist.saveOAuth(oauth)
|
||||
}
|
||||
|
||||
private fun refreshToken(chain: Interceptor.Chain): OAuth =
|
||||
private fun refreshToken(chain: Interceptor.Chain): MALOAuth =
|
||||
synchronized(this) {
|
||||
if (myanimelist.getIfAuthExpired()) throw TokenExpired()
|
||||
if (tokenExpired) throw MALTokenExpired()
|
||||
oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it }
|
||||
|
||||
val response =
|
||||
try {
|
||||
chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!))
|
||||
} catch (_: Throwable) {
|
||||
throw TokenRefreshFailed()
|
||||
throw MALTokenRefreshFailed()
|
||||
}
|
||||
|
||||
if (response.code == 401) {
|
||||
myanimelist.setAuthExpired()
|
||||
throw TokenExpired()
|
||||
throw MALTokenExpired()
|
||||
}
|
||||
|
||||
return runCatching {
|
||||
if (response.isSuccessful) {
|
||||
with(json) { response.parseAs<OAuth>() }
|
||||
with(json) { response.parseAs<MALOAuth>() }
|
||||
} else {
|
||||
response.close()
|
||||
null
|
||||
}
|
||||
}.getOrNull()
|
||||
?.also(::setAuth)
|
||||
?: throw TokenRefreshFailed()
|
||||
?.also {
|
||||
this.oauth = it
|
||||
myanimelist.saveOAuth(it)
|
||||
}
|
||||
?: throw MALTokenRefreshFailed()
|
||||
}
|
||||
}
|
||||
|
||||
class MALTokenRefreshFailed : IOException("MAL: Failed to refresh account token")
|
||||
|
||||
class MALTokenExpired : IOException("MAL: Login has expired")
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
|
||||
@Serializable
|
||||
data class OAuth(
|
||||
val refresh_token: String,
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val created_at: Long = System.currentTimeMillis(),
|
||||
val expires_in: Long,
|
||||
)
|
||||
|
||||
fun OAuth.isExpired() = System.currentTimeMillis() > created_at + (expires_in * 1000)
|
||||
|
||||
fun Track.toMyAnimeListStatus() =
|
||||
when (status) {
|
||||
MyAnimeList.READING -> "reading"
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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 = "",
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class MALUser(
|
||||
val name: String,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -22,4 +22,5 @@ data class TrackRecordDataClass(
|
||||
val remoteUrl: String,
|
||||
val startDate: Long,
|
||||
val finishDate: Long,
|
||||
val private: Boolean,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,9 @@ data class TrackSearchDataClass(
|
||||
val id: Int,
|
||||
val trackerId: Int,
|
||||
val remoteId: Long,
|
||||
val libraryId: Long?,
|
||||
val title: String,
|
||||
val lastChapterRead: Double,
|
||||
val totalChapters: Int,
|
||||
val trackingUrl: String,
|
||||
val coverUrl: String,
|
||||
@@ -22,4 +24,10 @@ data class TrackSearchDataClass(
|
||||
val publishingStatus: String,
|
||||
val publishingType: String,
|
||||
val startDate: String,
|
||||
val status: Int,
|
||||
val score: Double,
|
||||
var scoreString: String?,
|
||||
val startedReadingDate: Long,
|
||||
val finishedReadingDate: Long,
|
||||
val private: Boolean,
|
||||
)
|
||||
|
||||
@@ -24,4 +24,5 @@ object TrackRecordTable : IntIdTable() {
|
||||
val remoteUrl = varchar("remote_url", 512)
|
||||
val startDate = long("start_date")
|
||||
val finishDate = long("finish_date")
|
||||
val private = bool("private").default(false)
|
||||
}
|
||||
|
||||
@@ -29,13 +29,22 @@ object TrackSearchTable : IntIdTable() {
|
||||
val publishingStatus = truncatingVarchar("publishing_status", 512)
|
||||
val publishingType = truncatingVarchar("publishing_type", 512)
|
||||
val startDate = truncatingVarchar("start_date", 128)
|
||||
val libraryId = long("library_id").nullable().default(null)
|
||||
val lastChapterRead = double("last_chapter_read").default(0.0)
|
||||
val status = integer("status").default(0)
|
||||
val score = double("score").default(0.0)
|
||||
val startedReadingDate = long("started_reading_date").default(0)
|
||||
val finishedReadingDate = long("finished_reading_date").default(0)
|
||||
val private = bool("private").default(false)
|
||||
val authors = truncatingVarchar("authors", 256).nullable().default(null)
|
||||
val artists = truncatingVarchar("artists", 256).nullable().default(null)
|
||||
}
|
||||
|
||||
fun List<TrackSearch>.insertAll(): List<ResultRow> {
|
||||
if (isEmpty()) return emptyList()
|
||||
return transaction {
|
||||
val trackerIds = map { it.sync_id }.toSet()
|
||||
val remoteIds = map { it.media_id }.toSet()
|
||||
val trackerIds = map { it.tracker_id }.toSet()
|
||||
val remoteIds = map { it.remote_id }.toSet()
|
||||
val existing =
|
||||
transaction {
|
||||
TrackSearchTable
|
||||
@@ -50,8 +59,8 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
|
||||
forEach { trackSearch ->
|
||||
val existingRow =
|
||||
existing.find {
|
||||
it[TrackSearchTable.trackerId] == trackSearch.sync_id &&
|
||||
it[TrackSearchTable.remoteId] == trackSearch.media_id
|
||||
it[TrackSearchTable.trackerId] == trackSearch.tracker_id &&
|
||||
it[TrackSearchTable.remoteId] == trackSearch.remote_id
|
||||
}
|
||||
grouped
|
||||
.getOrPut(existingRow != null) { mutableListOf() }
|
||||
@@ -72,6 +81,15 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
|
||||
this[TrackSearchTable.publishingStatus] = trackSearch.publishing_status
|
||||
this[TrackSearchTable.publishingType] = trackSearch.publishing_type
|
||||
this[TrackSearchTable.startDate] = trackSearch.start_date
|
||||
this[TrackSearchTable.libraryId] = trackSearch.library_id
|
||||
this[TrackSearchTable.lastChapterRead] = trackSearch.last_chapter_read
|
||||
this[TrackSearchTable.status] = trackSearch.status
|
||||
this[TrackSearchTable.score] = trackSearch.score
|
||||
this[TrackSearchTable.startedReadingDate] = trackSearch.started_reading_date
|
||||
this[TrackSearchTable.finishedReadingDate] = trackSearch.finished_reading_date
|
||||
this[TrackSearchTable.private] = trackSearch.private
|
||||
this[TrackSearchTable.authors] = trackSearch.authors.ifEmpty { null }?.joinToString(",")
|
||||
this[TrackSearchTable.artists] = trackSearch.artists.ifEmpty { null }?.joinToString(",")
|
||||
}
|
||||
execute(this@transaction)
|
||||
}
|
||||
@@ -79,8 +97,8 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
|
||||
val insertedRows =
|
||||
if (!toInsert.isNullOrEmpty()) {
|
||||
TrackSearchTable.batchInsert(toInsert) {
|
||||
this[TrackSearchTable.trackerId] = it.sync_id
|
||||
this[TrackSearchTable.remoteId] = it.media_id
|
||||
this[TrackSearchTable.trackerId] = it.tracker_id
|
||||
this[TrackSearchTable.remoteId] = it.remote_id
|
||||
this[TrackSearchTable.title] = it.title
|
||||
this[TrackSearchTable.totalChapters] = it.total_chapters
|
||||
this[TrackSearchTable.trackingUrl] = it.tracking_url
|
||||
@@ -89,6 +107,15 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
|
||||
this[TrackSearchTable.publishingStatus] = it.publishing_status
|
||||
this[TrackSearchTable.publishingType] = it.publishing_type
|
||||
this[TrackSearchTable.startDate] = it.start_date
|
||||
this[TrackSearchTable.libraryId] = it.library_id
|
||||
this[TrackSearchTable.lastChapterRead] = it.last_chapter_read
|
||||
this[TrackSearchTable.status] = it.status
|
||||
this[TrackSearchTable.score] = it.score
|
||||
this[TrackSearchTable.startedReadingDate] = it.started_reading_date
|
||||
this[TrackSearchTable.finishedReadingDate] = it.finished_reading_date
|
||||
this[TrackSearchTable.private] = it.private
|
||||
this[TrackSearchTable.authors] = it.authors.ifEmpty { null }?.joinToString(",")
|
||||
this[TrackSearchTable.artists] = it.artists.ifEmpty { null }?.joinToString(",")
|
||||
}
|
||||
} else {
|
||||
emptyList()
|
||||
@@ -104,8 +131,8 @@ fun List<TrackSearch>.insertAll(): List<ResultRow> {
|
||||
(insertedRows + updatedRows)
|
||||
.sortedBy { row ->
|
||||
indexOfFirst {
|
||||
it.sync_id == row[TrackSearchTable.trackerId] &&
|
||||
it.media_id == row[TrackSearchTable.remoteId]
|
||||
it.tracker_id == row[TrackSearchTable.trackerId] &&
|
||||
it.remote_id == row[TrackSearchTable.remoteId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user