Update Tracking Backend (#1457)

* Update Tracking Library

* Update Bangumi

* Update Anilist

* Update MangaUpdates

* Update MAL

* Add private to bind track

* Use null

* Remove old nullable

* Remove custom implementation of supportsTrackDeletion

* Add private to updateTrack

* Some descriptions

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

View File

@@ -22,7 +22,9 @@ import suwayomi.tachidesk.graphql.types.TrackStatusType
import suwayomi.tachidesk.graphql.types.TrackerType
import suwayomi.tachidesk.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] }
}

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ import suwayomi.tachidesk.graphql.server.primitives.Node
import suwayomi.tachidesk.graphql.server.primitives.NodeList
import suwayomi.tachidesk.graphql.server.primitives.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(

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,6 @@ import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
/**
* For track services api that support deleting a manga entry for a user's list
*/
interface DeletableTrackService {
interface DeletableTracker {
suspend fun delete(track: Track)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,124 +0,0 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import java.text.SimpleDateFormat
import java.util.Locale
data class ALManga(
val media_id: Long,
val title_user_pref: String,
val image_url_lge: String,
val description: String?,
val format: String,
val publishing_status: String,
val start_date_fuzzy: Long,
val total_chapters: Int,
) {
fun toTrack() =
TrackSearch.create(TrackerManager.ANILIST).apply {
media_id = this@ALManga.media_id
title = title_user_pref
total_chapters = this@ALManga.total_chapters
cover_url = image_url_lge
summary = description ?: ""
tracking_url = AnilistApi.mangaUrl(media_id)
publishing_status = this@ALManga.publishing_status
publishing_type = format
if (start_date_fuzzy != 0L) {
start_date =
try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(start_date_fuzzy)
} catch (e: Exception) {
""
}
}
}
}
data class ALUserManga(
val library_id: Long,
val list_status: String,
val score_raw: Int,
val chapters_read: Int,
val start_date_fuzzy: Long,
val completed_date_fuzzy: Long,
val manga: ALManga,
) {
fun toTrack() =
Track.create(TrackerManager.ANILIST).apply {
media_id = manga.media_id
title = manga.title_user_pref
status = toTrackStatus()
score = score_raw.toFloat()
started_reading_date = start_date_fuzzy
finished_reading_date = completed_date_fuzzy
last_chapter_read = chapters_read.toFloat()
library_id = this@ALUserManga.library_id
total_chapters = manga.total_chapters
}
fun toTrackStatus() =
when (list_status) {
"CURRENT" -> Anilist.READING
"COMPLETED" -> Anilist.COMPLETED
"PAUSED" -> Anilist.ON_HOLD
"DROPPED" -> Anilist.DROPPED
"PLANNING" -> Anilist.PLAN_TO_READ
"REPEATING" -> Anilist.REREADING
else -> throw NotImplementedError("Unknown status: $list_status")
}
}
@Serializable
data class OAuth(
val access_token: String,
val token_type: String,
val expires: Long,
val expires_in: Long,
)
fun OAuth.isExpired() = System.currentTimeMillis() > expires
fun Track.toAnilistStatus() =
when (status) {
Anilist.READING -> "CURRENT"
Anilist.COMPLETED -> "COMPLETED"
Anilist.ON_HOLD -> "PAUSED"
Anilist.DROPPED -> "DROPPED"
Anilist.PLAN_TO_READ -> "PLANNING"
Anilist.REREADING -> "REPEATING"
else -> throw NotImplementedError("Unknown status: $status")
}
fun Track.toAnilistScore(scoreType: String?): String =
when (scoreType) {
// 10 point
"POINT_10" -> (score.toInt() / 10).toString()
// 100 point
"POINT_100" -> score.toInt().toString()
// 5 stars
"POINT_5" ->
when {
score == 0f -> "0"
score < 30 -> "1"
score < 50 -> "2"
score < 70 -> "3"
score < 90 -> "4"
else -> "5"
}
// Smiley
"POINT_3" ->
when {
score == 0f -> "0"
score <= 35 -> ":("
score <= 60 -> ":|"
else -> ":)"
}
// 10 point decimal
"POINT_10_DECIMAL" -> (score / 10).toString()
else -> throw NotImplementedError("Unknown score type")
}

View File

@@ -0,0 +1,43 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
fun Track.toApiStatus() =
when (status) {
Anilist.READING -> "CURRENT"
Anilist.COMPLETED -> "COMPLETED"
Anilist.ON_HOLD -> "PAUSED"
Anilist.DROPPED -> "DROPPED"
Anilist.PLAN_TO_READ -> "PLANNING"
Anilist.REREADING -> "REPEATING"
else -> throw NotImplementedError("Unknown status: $status")
}
fun Track.toApiScore(scoreType: String?): String =
when (scoreType) {
// 10 point
"POINT_10" -> (score.toInt() / 10).toString()
// 100 point
"POINT_100" -> score.toInt().toString()
// 5 stars
"POINT_5" ->
when {
score == 0.0 -> "0"
score < 30 -> "1"
score < 50 -> "2"
score < 70 -> "3"
score < 90 -> "4"
else -> "5"
}
// Smiley
"POINT_3" ->
when {
score == 0.0 -> "0"
score <= 35 -> ":("
score <= 60 -> ":|"
else -> ":)"
}
// 10 point decimal
"POINT_10_DECIMAL" -> (score / 10).toString()
else -> throw NotImplementedError("Unknown score type")
}

View File

@@ -0,0 +1,20 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALAddMangaResult(
val data: ALAddMangaData,
)
@Serializable
data class ALAddMangaData(
@SerialName("SaveMediaListEntry")
val entry: ALAddMangaEntry,
)
@Serializable
data class ALAddMangaEntry(
val id: Long,
)

View File

@@ -0,0 +1,23 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto
import kotlinx.serialization.Serializable
import java.time.LocalDate
import java.time.ZoneId
@Serializable
data class ALFuzzyDate(
val year: Int?,
val month: Int?,
val day: Int?,
) {
fun toEpochMilli(): Long =
try {
LocalDate
.of(year!!, month!!, day!!)
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
} catch (_: Exception) {
0L
}
}

View File

@@ -0,0 +1,86 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto
import suwayomi.tachidesk.manga.impl.track.Track.htmlDecode
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.AnilistApi
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import java.text.SimpleDateFormat
import java.util.Locale
data class ALManga(
val remoteId: Long,
val title: String,
val imageUrl: String,
val description: String?,
val format: String,
val publishingStatus: String,
val startDateFuzzy: Long,
val totalChapters: Int,
val averageScore: Int,
val staff: ALStaff,
) {
fun toTrack() =
TrackSearch.create(TrackerManager.ANILIST).apply {
remote_id = remoteId
title = this@ALManga.title
total_chapters = totalChapters
cover_url = imageUrl
summary = description?.htmlDecode() ?: ""
score = averageScore.toDouble()
tracking_url = AnilistApi.mangaUrl(remote_id)
publishing_status = publishingStatus
publishing_type = format
if (startDateFuzzy != 0L) {
start_date =
try {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(startDateFuzzy)
} catch (e: IllegalArgumentException) {
""
}
}
staff.edges.forEach {
val name = it.node.name() ?: return@forEach
if ("Story" in it.role) authors += name
if ("Art" in it.role) artists += name
}
}
}
data class ALUserManga(
val libraryId: Long,
val listStatus: String,
val scoreRaw: Int,
val chaptersRead: Int,
val startDateFuzzy: Long,
val completedDateFuzzy: Long,
val manga: ALManga,
val private: Boolean,
) {
fun toTrack() =
Track.create(TrackerManager.ANILIST).apply {
remote_id = manga.remoteId
title = manga.title
status = toTrackStatus()
score = scoreRaw.toDouble()
started_reading_date = startDateFuzzy
finished_reading_date = completedDateFuzzy
last_chapter_read = chaptersRead.toDouble()
library_id = libraryId
total_chapters = manga.totalChapters
private = this@ALUserManga.private
}
private fun toTrackStatus() =
when (listStatus) {
"CURRENT" -> Anilist.READING
"COMPLETED" -> Anilist.COMPLETED
"PAUSED" -> Anilist.ON_HOLD
"DROPPED" -> Anilist.DROPPED
"PLANNING" -> Anilist.PLAN_TO_READ
"REPEATING" -> Anilist.REREADING
else -> throw NotImplementedError("Unknown status: $listStatus")
}
}

View File

@@ -0,0 +1,17 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("token_type")
val tokenType: String,
val expires: Long,
@SerialName("expires_in")
val expiresIn: Long,
)
fun ALOAuth.isExpired() = System.currentTimeMillis() > expires

View File

@@ -0,0 +1,20 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALSearchResult(
val data: ALSearchPage,
)
@Serializable
data class ALSearchPage(
@SerialName("Page")
val page: ALSearchMedia,
)
@Serializable
data class ALSearchMedia(
val media: List<ALSearchItem>,
)

View File

@@ -0,0 +1,67 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto
import kotlinx.serialization.Serializable
@Serializable
data class ALSearchItem(
val id: Long,
val title: ALItemTitle,
val coverImage: ItemCover,
val description: String?,
val format: String,
val status: String?,
val startDate: ALFuzzyDate,
val chapters: Int?,
val averageScore: Int?,
val staff: ALStaff,
) {
fun toALManga(): ALManga =
ALManga(
remoteId = id,
title = title.userPreferred,
imageUrl = coverImage.large,
description = description,
format = format.replace("_", "-"),
publishingStatus = status ?: "",
startDateFuzzy = startDate.toEpochMilli(),
totalChapters = chapters ?: 0,
averageScore = averageScore ?: -1,
staff = staff,
)
}
@Serializable
data class ALItemTitle(
val userPreferred: String,
)
@Serializable
data class ItemCover(
val large: String,
)
@Serializable
data class ALStaff(
val edges: List<ALEdge>,
)
@Serializable
data class ALEdge(
val role: String,
val id: Int,
val node: ALStaffNode,
)
@Serializable
data class ALStaffNode(
val name: ALStaffName,
)
@Serializable
data class ALStaffName(
val userPreferred: String?,
val native: String?,
val full: String?,
) {
operator fun invoke(): String? = userPreferred ?: full ?: native
}

View File

@@ -0,0 +1,26 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALCurrentUserResult(
val data: ALUserViewer,
)
@Serializable
data class ALUserViewer(
@SerialName("Viewer")
val viewer: ALUserViewerData,
)
@Serializable
data class ALUserViewerData(
val id: Int,
val mediaListOptions: ALUserListOptions,
)
@Serializable
data class ALUserListOptions(
val scoreFormat: String,
)

View File

@@ -0,0 +1,44 @@
package suwayomi.tachidesk.manga.impl.track.tracker.anilist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ALUserListMangaQueryResult(
val data: ALUserListMangaPage,
)
@Serializable
data class ALUserListMangaPage(
@SerialName("Page")
val page: ALUserListMediaList,
)
@Serializable
data class ALUserListMediaList(
val mediaList: List<ALUserListItem>,
)
@Serializable
data class ALUserListItem(
val id: Long,
val status: String,
val scoreRaw: Int,
val progress: Int,
val startedAt: ALFuzzyDate,
val completedAt: ALFuzzyDate,
val media: ALSearchItem,
val private: Boolean,
) {
fun toALUserManga(): ALUserManga =
ALUserManga(
libraryId = this@ALUserListItem.id,
listStatus = status,
scoreRaw = scoreRaw,
chaptersRead = progress,
startDateFuzzy = startedAt.toEpochMilli(),
completedDateFuzzy = completedAt.toEpochMilli(),
manga = media.toALManga(),
private = private,
)
}

View File

@@ -3,6 +3,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import android.annotation.StringRes
import 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")
}

View File

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

View File

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

View File

@@ -1,115 +0,0 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMUser(
val username: String,
)
@Serializable
data class BGMSearchResult(
val total: Int,
val limit: Int,
val offset: Int,
val data: List<BGMSubject> = emptyList(),
)
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubject(
val id: Long,
@SerialName("name_cn")
val nameCn: String,
val name: String,
val summary: String?,
val date: String?, // YYYY-MM-DD
val images: BGMSubjectImages?,
val volumes: Long = 0,
val eps: Long = 0,
val rating: BGMSubjectRating?,
val platform: String?,
) {
fun toTrackSearch(trackId: Int): TrackSearch =
TrackSearch.create(TrackerManager.BANGUMI).apply {
media_id = this@BGMSubject.id
title = nameCn.ifBlank { name }
cover_url = images?.common.orEmpty()
summary =
if (nameCn.isNotBlank()) {
"作品原名:$name" + this@BGMSubject.summary?.let { "\n${it.trim()}" }.orEmpty()
} else {
this@BGMSubject.summary?.trim().orEmpty()
}
tracking_url = "https://bangumi.tv/subject/${this@BGMSubject.id}"
total_chapters = eps.toInt()
start_date = date ?: ""
}
}
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubjectImages(
val common: String?,
)
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubjectRating(
val score: Double?,
)
@Serializable
data class BGMOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("token_type")
val tokenType: String,
@SerialName("created_at")
@EncodeDefault
val createdAt: Long = System.currentTimeMillis() / 1000,
@SerialName("expires_in")
val expiresIn: Long,
@SerialName("refresh_token")
val refreshToken: String?,
@SerialName("user_id")
val userId: Long?,
)
// Access token refresh before expired
fun BGMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMCollectionResponse(
val rate: Int?,
val type: Int?,
@SerialName("ep_status")
val epStatus: Int? = 0,
@SerialName("vol_status")
val volStatus: Int? = 0,
val private: Boolean = false,
val subject: BGMSlimSubject? = null,
) {
fun getStatus(): Int =
when (type) {
1 -> Bangumi.PLAN_TO_READ
2 -> Bangumi.COMPLETED
3 -> Bangumi.READING
4 -> Bangumi.ON_HOLD
5 -> Bangumi.DROPPED
else -> throw NotImplementedError("Unknown status: $type")
}
}
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSlimSubject(
val volumes: Int?,
val eps: Int?,
)

View File

@@ -0,0 +1,13 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
fun Track.toApiStatus() =
when (status) {
Bangumi.PLAN_TO_READ -> 1
Bangumi.COMPLETED -> 2
Bangumi.READING -> 3
Bangumi.ON_HOLD -> 4
Bangumi.DROPPED -> 5
else -> throw NotImplementedError("Unknown status: $status")
}

View File

@@ -0,0 +1,35 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.bangumi.Bangumi
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMCollectionResponse(
val rate: Int?,
val type: Int?,
@SerialName("ep_status")
val epStatus: Int? = 0,
@SerialName("vol_status")
val volStatus: Int? = 0,
val private: Boolean = false,
val subject: BGMSlimSubject? = null,
) {
fun getStatus(): Int =
when (type) {
1 -> Bangumi.PLAN_TO_READ
2 -> Bangumi.COMPLETED
3 -> Bangumi.READING
4 -> Bangumi.ON_HOLD
5 -> Bangumi.DROPPED
else -> throw NotImplementedError("Unknown status: $type")
}
}
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSlimSubject(
val volumes: Int?,
val eps: Int?,
)

View File

@@ -0,0 +1,25 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BGMOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("token_type")
val tokenType: String,
@SerialName("created_at")
@EncodeDefault
val createdAt: Long = System.currentTimeMillis() / 1000,
@SerialName("expires_in")
val expiresIn: Long,
@SerialName("refresh_token")
val refreshToken: String?,
@SerialName("user_id")
val userId: Long?,
)
// Access token refresh before expired
fun BGMOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)

View File

@@ -0,0 +1,58 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
@Serializable
data class BGMSearchResult(
val total: Int,
val limit: Int,
val offset: Int,
val data: List<BGMSubject> = emptyList(),
)
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubject(
val id: Long,
@SerialName("name_cn")
val nameCn: String,
val name: String,
val summary: String?,
val date: String?, // YYYY-MM-DD
val images: BGMSubjectImages?,
val volumes: Long = 0,
val eps: Int = 0,
val rating: BGMSubjectRating?,
val platform: String?,
) {
fun toTrackSearch(trackId: Int): TrackSearch =
TrackSearch.create(trackId).apply {
remote_id = this@BGMSubject.id
title = nameCn.ifBlank { name }
cover_url = images?.common.orEmpty()
summary =
if (nameCn.isNotBlank()) {
"作品原名:$name" + this@BGMSubject.summary?.let { "\n${it.trim()}" }.orEmpty()
} else {
this@BGMSubject.summary?.trim().orEmpty()
}
score = rating?.score ?: -1.0
tracking_url = "https://bangumi.tv/subject/${this@BGMSubject.id}"
total_chapters = eps
start_date = date ?: ""
}
}
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubjectImages(
val common: String?,
)
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMSubjectRating(
val score: Double?,
)

View File

@@ -0,0 +1,9 @@
package suwayomi.tachidesk.manga.impl.track.tracker.bangumi.dto
import kotlinx.serialization.Serializable
@Serializable
// Incomplete DTO with only our needed attributes
data class BGMUser(
val username: String,
)

View File

@@ -1,9 +1,9 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
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
}

View File

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

View File

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

View File

@@ -1,147 +0,0 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.int
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class KitsuSearchManga(
obj: JsonObject,
) {
val id = obj["id"]!!.jsonPrimitive.long
private val canonicalTitle = obj["canonicalTitle"]!!.jsonPrimitive.content
private val chapterCount = obj["chapterCount"]?.jsonPrimitive?.intOrNull
val subType = obj["subtype"]?.jsonPrimitive?.contentOrNull
val original =
try {
obj["posterImage"]
?.jsonObject
?.get("original")
?.jsonPrimitive
?.content
} catch (e: IllegalArgumentException) {
// posterImage is sometimes a jsonNull object instead
null
}
private val synopsis = obj["synopsis"]?.jsonPrimitive?.contentOrNull
private val rating = obj["averageRating"]?.jsonPrimitive?.contentOrNull?.toDoubleOrNull()
private var startDate =
obj["startDate"]?.jsonPrimitive?.contentOrNull?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(Date(it.toLong() * 1000))
}
private val endDate = obj["endDate"]?.jsonPrimitive?.contentOrNull
fun toTrack() =
TrackSearch.create(TrackerManager.KITSU).apply {
media_id = this@KitsuSearchManga.id
title = canonicalTitle
total_chapters = chapterCount ?: 0
cover_url = original ?: ""
summary = synopsis ?: ""
tracking_url = KitsuApi.mangaUrl(media_id)
// score = rating ?: -1.0
publishing_status =
if (endDate == null) {
"Publishing"
} else {
"Finished"
}
publishing_type = subType ?: ""
start_date = startDate ?: ""
}
}
class KitsuLibManga(
obj: JsonObject,
manga: JsonObject,
) {
val id = manga["id"]!!.jsonPrimitive.int
private val canonicalTitle = manga["attributes"]!!.jsonObject["canonicalTitle"]!!.jsonPrimitive.content
private val chapterCount = manga["attributes"]!!.jsonObject["chapterCount"]?.jsonPrimitive?.intOrNull
val type =
manga["attributes"]!!
.jsonObject["mangaType"]
?.jsonPrimitive
?.contentOrNull
.orEmpty()
val original =
manga["attributes"]!!
.jsonObject["posterImage"]!!
.jsonObject["original"]!!
.jsonPrimitive.content
private val synopsis = manga["attributes"]!!.jsonObject["synopsis"]!!.jsonPrimitive.content
private val startDate =
manga["attributes"]!!
.jsonObject["startDate"]
?.jsonPrimitive
?.contentOrNull
.orEmpty()
private val startedAt = obj["attributes"]!!.jsonObject["startedAt"]?.jsonPrimitive?.contentOrNull
private val finishedAt = obj["attributes"]!!.jsonObject["finishedAt"]?.jsonPrimitive?.contentOrNull
private val libraryId = obj["id"]!!.jsonPrimitive.long
val status = obj["attributes"]!!.jsonObject["status"]!!.jsonPrimitive.content
private val ratingTwenty = obj["attributes"]!!.jsonObject["ratingTwenty"]?.jsonPrimitive?.contentOrNull
val progress = obj["attributes"]!!.jsonObject["progress"]!!.jsonPrimitive.int
fun toTrack() =
Track.create(TrackerManager.KITSU).apply {
media_id = libraryId
title = canonicalTitle
total_chapters = chapterCount ?: 0
// cover_url = original
// summary = synopsis
tracking_url = KitsuApi.mangaUrl(media_id)
// publishing_status = this@KitsuLibManga.status
// publishing_type = type
// start_date = startDate
started_reading_date = KitsuDateHelper.parse(startedAt)
finished_reading_date = KitsuDateHelper.parse(finishedAt)
status = toTrackStatus()
score = ratingTwenty?.let { it.toInt() / 2.0f } ?: 0.0f
last_chapter_read = progress.toFloat()
}
private fun toTrackStatus() =
when (status) {
"current" -> Kitsu.READING
"completed" -> Kitsu.COMPLETED
"on_hold" -> Kitsu.ON_HOLD
"dropped" -> Kitsu.DROPPED
"planned" -> Kitsu.PLAN_TO_READ
else -> throw Exception("Unknown status")
}
}
@Serializable
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?,
)
fun OAuth.isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
fun Track.toKitsuStatus() =
when (status) {
Kitsu.READING -> "current"
Kitsu.COMPLETED -> "completed"
Kitsu.ON_HOLD -> "on_hold"
Kitsu.DROPPED -> "dropped"
Kitsu.PLAN_TO_READ -> "planned"
else -> throw Exception("Unknown status")
}
fun Track.toKitsuScore(): String? = if (score > 0) (score * 2).toInt().toString() else null

View File

@@ -0,0 +1,15 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
fun Track.toApiStatus() =
when (status) {
Kitsu.READING -> "current"
Kitsu.COMPLETED -> "completed"
Kitsu.ON_HOLD -> "on_hold"
Kitsu.DROPPED -> "dropped"
Kitsu.PLAN_TO_READ -> "planned"
else -> throw Exception("Unknown status")
}
fun Track.toApiScore(): String? = if (score > 0) (score * 2).toInt().toString() else null

View File

@@ -0,0 +1,13 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto
import kotlinx.serialization.Serializable
@Serializable
data class KitsuAddMangaResult(
val data: KitsuAddMangaItem,
)
@Serializable
data class KitsuAddMangaItem(
val id: Long,
)

View File

@@ -0,0 +1,82 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.Kitsu
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.KitsuApi
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.KitsuDateHelper
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
@Serializable
data class KitsuListSearchResult(
val data: List<KitsuListSearchItemData>,
val included: List<KitsuListSearchItemIncluded> = emptyList(),
) {
fun firstToTrack(): TrackSearch {
require(data.isNotEmpty()) { "Missing User data from Kitsu" }
require(included.isNotEmpty()) { "Missing Manga data from Kitsu" }
val userData = data[0]
val userDataAttrs = userData.attributes
val manga = included[0].attributes
return TrackSearch.create(TrackerManager.KITSU).apply {
remote_id = userData.id
title = manga.canonicalTitle
total_chapters = manga.chapterCount ?: 0
cover_url = manga.posterImage?.original ?: ""
summary = manga.synopsis ?: ""
tracking_url = KitsuApi.mangaUrl(remote_id)
publishing_status = manga.status
publishing_type = manga.mangaType ?: ""
start_date = userDataAttrs.startedAt ?: ""
started_reading_date = KitsuDateHelper.parse(userDataAttrs.startedAt)
finished_reading_date = KitsuDateHelper.parse(userDataAttrs.finishedAt)
status =
when (userDataAttrs.status) {
"current" -> Kitsu.READING
"completed" -> Kitsu.COMPLETED
"on_hold" -> Kitsu.ON_HOLD
"dropped" -> Kitsu.DROPPED
"planned" -> Kitsu.PLAN_TO_READ
else -> throw Exception("Unknown status")
}
score = userDataAttrs.ratingTwenty?.let { it / 2.0 } ?: 0.0
last_chapter_read = userDataAttrs.progress.toDouble()
private = userDataAttrs.private
}
}
}
@Serializable
data class KitsuListSearchItemData(
val id: Long,
val attributes: KitsuListSearchItemDataAttributes,
)
@Serializable
data class KitsuListSearchItemDataAttributes(
val status: String,
val startedAt: String?,
val finishedAt: String?,
val ratingTwenty: Int?,
val progress: Int,
val private: Boolean,
)
@Serializable
data class KitsuListSearchItemIncluded(
val id: Long,
val attributes: KitsuListSearchItemIncludedAttributes,
)
@Serializable
data class KitsuListSearchItemIncludedAttributes(
val canonicalTitle: String,
val chapterCount: Int?,
val mangaType: String?,
val posterImage: KitsuSearchItemCover?,
val synopsis: String?,
val startDate: String?,
val status: String,
)

View File

@@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.data.track.kitsu.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class KitsuOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("token_type")
val tokenType: String,
@SerialName("created_at")
val createdAt: Long,
@SerialName("expires_in")
val expiresIn: Long,
@SerialName("refresh_token")
val refreshToken: String?,
)
fun KitsuOAuth.isExpired() = (System.currentTimeMillis() / 1000) > (createdAt + expiresIn - 3600)

View File

@@ -0,0 +1,54 @@
package suwayomi.tachidesk.manga.impl.track.tracker.kitsu.dto
import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.TrackerManager
import suwayomi.tachidesk.manga.impl.track.tracker.kitsu.KitsuApi
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Serializable
data class KitsuSearchResult(
val media: KitsuSearchResultData,
)
@Serializable
data class KitsuSearchResultData(
val key: String,
)
@Serializable
data class KitsuAlgoliaSearchResult(
val hits: List<KitsuAlgoliaSearchItem>,
)
@Serializable
data class KitsuAlgoliaSearchItem(
val id: Long,
val canonicalTitle: String,
val chapterCount: Int?,
val subtype: String?,
val posterImage: KitsuSearchItemCover?,
val synopsis: String?,
val averageRating: Double?,
val startDate: Long?,
val endDate: Long?,
) {
fun toTrack(): TrackSearch =
TrackSearch.create(TrackerManager.KITSU).apply {
remote_id = this@KitsuAlgoliaSearchItem.id
title = canonicalTitle
total_chapters = chapterCount ?: 0
cover_url = posterImage?.original ?: ""
summary = synopsis ?: ""
tracking_url = KitsuApi.mangaUrl(remote_id)
score = averageRating ?: -1.0
publishing_status = if (endDate == null) "Publishing" else "Finished"
publishing_type = subtype ?: ""
start_date = startDate?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
outputDf.format(Date(it * 1000))
} ?: ""
}
}

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates
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 }
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,16 +6,16 @@ import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Com
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
@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
}

View File

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

View File

@@ -4,11 +4,11 @@ import kotlinx.serialization.Serializable
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
@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
}

View File

@@ -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 ?: ""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import suwayomi.tachidesk.manga.model.dataclass.TrackRecordDataClass
import suwayomi.tachidesk.manga.model.table.TrackRecordTable
import suwayomi.tachidesk.manga.model.table.TrackRecordTable.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,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,26 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MALListItem(
@SerialName("num_chapters")
val numChapters: Int,
@SerialName("my_list_status")
val myListStatus: MALListItemStatus?,
)
@Serializable
data class MALListItemStatus(
@SerialName("is_rereading")
val isRereading: Boolean,
val status: String,
@SerialName("num_chapters_read")
val numChaptersRead: Double,
val score: Int,
@SerialName("start_date")
val startDate: String?,
@SerialName("finish_date")
val finishDate: String?,
)

View File

@@ -0,0 +1,26 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MALManga(
val id: Long,
val title: String,
val synopsis: String = "",
@SerialName("num_chapters")
val numChapters: Int,
val mean: Double = -1.0,
@SerialName("main_picture")
val covers: MALMangaCovers?,
val status: String,
@SerialName("media_type")
val mediaType: String,
@SerialName("start_date")
val startDate: String?,
)
@Serializable
data class MALMangaCovers(
val large: String = "",
)

View File

@@ -0,0 +1,25 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto
import kotlinx.serialization.EncodeDefault
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MALOAuth(
@SerialName("token_type")
val tokenType: String,
@SerialName("refresh_token")
val refreshToken: String,
@SerialName("access_token")
val accessToken: String,
@SerialName("expires_in")
val expiresIn: Long,
@SerialName("created_at")
@EncodeDefault
val createdAt: Long = System.currentTimeMillis() / 1000,
) {
// Assumes expired a minute earlier
private val adjustedExpiresIn: Long = (expiresIn - 60)
fun isExpired() = createdAt + adjustedExpiresIn < System.currentTimeMillis() / 1000
}

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.dto
import kotlinx.serialization.Serializable
@Serializable
data class MALUserSearchResult(
val data: List<MALUserSearchItem>,
val paging: MALUserSearchPaging,
)
@Serializable
data class MALUserSearchItem(
val node: MALUserSearchItemNode,
)
@Serializable
data class MALUserSearchPaging(
val next: String?,
)
@Serializable
data class MALUserSearchItemNode(
val id: Int,
val title: String,
)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
package suwayomi.tachidesk.server.database.migration
import de.neonew.exposed.migrations.helpers.SQLMigration
@Suppress("ClassName", "unused")
class M0048_AddTrackingColumns : SQLMigration() {
fun createNewColumn(
tableName: String,
columnName: String,
columnType: String,
default: String,
notNull: Boolean = false,
) = "ALTER TABLE $tableName" +
" ADD COLUMN $columnName $columnType DEFAULT $default${if (notNull) " NOT NULL" else ""};"
override val sql: String =
"""
${createNewColumn("TRACKRECORD", "PRIVATE", "BOOLEAN", "FALSE", notNull = true)}
${createNewColumn("TRACKSEARCH", "LIBRARY_ID", "BIGINT", "NULL")}
${createNewColumn("TRACKSEARCH", "LAST_CHAPTER_READ", "DOUBLE PRECISION", "0", notNull = true)}
${createNewColumn("TRACKSEARCH", "STATUS", "INT", "0", notNull = true)}
${createNewColumn("TRACKSEARCH", "SCORE", "DOUBLE PRECISION", "0", notNull = true)}
${createNewColumn("TRACKSEARCH", "STARTED_READING_DATE", "BIGINT", "0", notNull = true)}
${createNewColumn("TRACKSEARCH", "FINISHED_READING_DATE", "BIGINT", "0", notNull = true)}
${createNewColumn("TRACKSEARCH", "PRIVATE", "BOOLEAN", "FALSE", notNull = true)}
${createNewColumn("TRACKSEARCH", "AUTHORS", "VARCHAR(256)", "NULL")}
${createNewColumn("TRACKSEARCH", "ARTISTS", "VARCHAR(256)", "NULL")}
""".trimIndent()
}