mirror of
https://github.com/Suwayomi/Tachidesk.git
synced 2026-01-06 03:42:34 +01:00
Add MangaUpdates (#834)
This commit is contained in:
@@ -54,3 +54,31 @@ fun POST(
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun PUT(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.put(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
fun DELETE(
|
||||
url: String,
|
||||
headers: Headers = DEFAULT_HEADERS,
|
||||
body: RequestBody = DEFAULT_BODY,
|
||||
cache: CacheControl = DEFAULT_CACHE_CONTROL,
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(url)
|
||||
.delete(body)
|
||||
.headers(headers)
|
||||
.cacheControl(cache)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker
|
||||
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.anilist.Anilist
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.myanimelist.MyAnimeList
|
||||
|
||||
object TrackerManager {
|
||||
@@ -16,15 +17,16 @@ object TrackerManager {
|
||||
|
||||
val myAnimeList = MyAnimeList(MYANIMELIST)
|
||||
val aniList = Anilist(ANILIST)
|
||||
|
||||
// val kitsu = Kitsu(KITSU)
|
||||
// val shikimori = Shikimori(SHIKIMORI)
|
||||
// val bangumi = Bangumi(BANGUMI)
|
||||
// val komga = Komga(KOMGA)
|
||||
// val mangaUpdates = MangaUpdates(MANGA_UPDATES)
|
||||
val mangaUpdates = MangaUpdates(MANGA_UPDATES)
|
||||
// val kavita = Kavita(context, KAVITA)
|
||||
// val suwayomi = Suwayomi(SUWAYOMI)
|
||||
|
||||
val services: List<Tracker> = listOf(myAnimeList, aniList)
|
||||
val services: List<Tracker> = listOf(myAnimeList, aniList, mangaUpdates)
|
||||
|
||||
fun getTracker(id: Int) = services.find { it.id == id }
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates
|
||||
|
||||
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.copyTo
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto.toTrackSearch
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch
|
||||
|
||||
class MangaUpdates(id: Int) : Tracker(id, "MangaUpdates") {
|
||||
// , DeletableTracker
|
||||
companion object {
|
||||
const val READING_LIST = 0
|
||||
const val WISH_LIST = 1
|
||||
const val COMPLETE_LIST = 2
|
||||
const val UNFINISHED_LIST = 3
|
||||
const val ON_HOLD_LIST = 4
|
||||
|
||||
private val SCORE_LIST =
|
||||
(0..10)
|
||||
.flatMap { decimal ->
|
||||
when (decimal) {
|
||||
0 -> listOf("-")
|
||||
10 -> listOf("10.0")
|
||||
else ->
|
||||
(0..9).map { fraction ->
|
||||
"$decimal.$fraction"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val interceptor by lazy { MangaUpdatesInterceptor(this) }
|
||||
|
||||
private val api by lazy { MangaUpdatesApi(interceptor, client) }
|
||||
|
||||
override fun getLogo(): String = "/static/tracker/manga_updates.png"
|
||||
|
||||
override fun getStatusList(): List<Int> {
|
||||
return listOf(READING_LIST, COMPLETE_LIST, ON_HOLD_LIST, UNFINISHED_LIST, WISH_LIST)
|
||||
}
|
||||
|
||||
override fun getStatus(status: Int): String? =
|
||||
when (status) {
|
||||
READING_LIST -> "Reading List"
|
||||
WISH_LIST -> "Wish List"
|
||||
COMPLETE_LIST -> "Complete List"
|
||||
ON_HOLD_LIST -> "On Hold List"
|
||||
UNFINISHED_LIST -> "Unfinished List"
|
||||
else -> null
|
||||
}
|
||||
|
||||
override fun getReadingStatus(): Int = READING_LIST
|
||||
|
||||
override fun getRereadingStatus(): Int = -1
|
||||
|
||||
override fun getCompletionStatus(): Int = COMPLETE_LIST
|
||||
|
||||
override fun getScoreList(): List<String> = SCORE_LIST
|
||||
|
||||
override fun indexToScore(index: Int): Float = if (index == 0) 0f else SCORE_LIST[index].toFloat()
|
||||
|
||||
override fun displayScore(track: Track): String = track.score.toString()
|
||||
|
||||
override suspend fun update(
|
||||
track: Track,
|
||||
didReadChapter: Boolean,
|
||||
): Track {
|
||||
if (track.status != COMPLETE_LIST && didReadChapter) {
|
||||
track.status = READING_LIST
|
||||
}
|
||||
api.updateSeriesListItem(track)
|
||||
return track
|
||||
}
|
||||
|
||||
// override suspend fun delete(track: Track) {
|
||||
// api.deleteSeriesFromList(track)
|
||||
// }
|
||||
|
||||
override suspend fun bind(
|
||||
track: Track,
|
||||
hasReadChapters: Boolean,
|
||||
): Track {
|
||||
return try {
|
||||
val (series, rating) = api.getSeriesListItem(track)
|
||||
track.copyFrom(series, rating)
|
||||
} catch (e: Exception) {
|
||||
track.score = 0f
|
||||
api.addSeriesToList(track, hasReadChapters)
|
||||
track
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun search(query: String): List<TrackSearch> {
|
||||
return api.search(query)
|
||||
.map {
|
||||
it.toTrackSearch(id)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refresh(track: Track): Track {
|
||||
val (series, rating) = api.getSeriesListItem(track)
|
||||
return track.copyFrom(series, rating)
|
||||
}
|
||||
|
||||
private fun Track.copyFrom(
|
||||
item: ListItem,
|
||||
rating: Rating?,
|
||||
): Track =
|
||||
apply {
|
||||
item.copyTo(this)
|
||||
score = rating?.rating ?: 0f
|
||||
}
|
||||
|
||||
override suspend fun login(
|
||||
username: String,
|
||||
password: String,
|
||||
) {
|
||||
val authenticated = api.authenticate(username, password) ?: throw Throwable("Unable to login")
|
||||
saveCredentials(authenticated.uid.toString(), authenticated.sessionToken)
|
||||
interceptor.newAuth(authenticated.sessionToken)
|
||||
}
|
||||
|
||||
fun restoreSession(): String? {
|
||||
return trackPreferences.getTrackPassword(this)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates
|
||||
|
||||
import eu.kanade.tachiyomi.network.DELETE
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
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
|
||||
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.model.Track
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class MangaUpdatesApi(
|
||||
interceptor: MangaUpdatesInterceptor,
|
||||
private val client: OkHttpClient,
|
||||
) {
|
||||
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()
|
||||
.addInterceptor(interceptor)
|
||||
.build()
|
||||
}
|
||||
|
||||
suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> {
|
||||
val listItem =
|
||||
with(json) {
|
||||
authClient.newCall(GET("$baseUrl/v1/lists/series/${track.media_id}"))
|
||||
.awaitSuccess()
|
||||
.parseAs<ListItem>()
|
||||
}
|
||||
|
||||
val rating = getSeriesRating(track)
|
||||
|
||||
return listItem to rating
|
||||
}
|
||||
|
||||
suspend fun addSeriesToList(
|
||||
track: Track,
|
||||
hasReadChapters: Boolean,
|
||||
) {
|
||||
val status = if (hasReadChapters) READING_LIST else WISH_LIST
|
||||
val body =
|
||||
buildJsonArray {
|
||||
addJsonObject {
|
||||
putJsonObject("series") {
|
||||
put("id", track.media_id)
|
||||
}
|
||||
put("list_id", status)
|
||||
}
|
||||
}
|
||||
authClient.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.let {
|
||||
if (it.code == 200) {
|
||||
track.status = status
|
||||
track.last_chapter_read = 1f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateSeriesListItem(track: Track) {
|
||||
val body =
|
||||
buildJsonArray {
|
||||
addJsonObject {
|
||||
putJsonObject("series") {
|
||||
put("id", track.media_id)
|
||||
}
|
||||
put("list_id", track.status)
|
||||
putJsonObject("status") {
|
||||
put("chapter", track.last_chapter_read.toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
authClient.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series/update",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
|
||||
updateSeriesRating(track)
|
||||
}
|
||||
|
||||
suspend fun deleteSeriesFromList(track: Track) {
|
||||
val body =
|
||||
buildJsonArray {
|
||||
add(track.media_id)
|
||||
}
|
||||
authClient.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/lists/series/delete",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
}
|
||||
|
||||
private suspend fun getSeriesRating(track: Track): Rating? {
|
||||
return try {
|
||||
with(json) {
|
||||
authClient.newCall(GET("$baseUrl/v1/series/${track.media_id}/rating"))
|
||||
.awaitSuccess()
|
||||
.parseAs<Rating>()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateSeriesRating(track: Track) {
|
||||
if (track.score != 0f) {
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("rating", track.score)
|
||||
}
|
||||
authClient.newCall(
|
||||
PUT(
|
||||
url = "$baseUrl/v1/series/${track.media_id}/rating",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
} else {
|
||||
authClient.newCall(
|
||||
DELETE(
|
||||
url = "$baseUrl/v1/series/${track.media_id}/rating",
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun search(query: String): List<Record> {
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("search", query)
|
||||
put(
|
||||
"filter_types",
|
||||
buildJsonArray {
|
||||
add("drama cd")
|
||||
add("novel")
|
||||
},
|
||||
)
|
||||
}
|
||||
return with(json) {
|
||||
client.newCall(
|
||||
POST(
|
||||
url = "$baseUrl/v1/series/search",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
obj["results"]?.jsonArray?.map { element ->
|
||||
json.decodeFromJsonElement<Record>(element.jsonObject["record"]!!)
|
||||
}
|
||||
}
|
||||
.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun authenticate(
|
||||
username: String,
|
||||
password: String,
|
||||
): Context? {
|
||||
val body =
|
||||
buildJsonObject {
|
||||
put("username", username)
|
||||
put("password", password)
|
||||
}
|
||||
return with(json) {
|
||||
client.newCall(
|
||||
PUT(
|
||||
url = "$baseUrl/v1/account/login",
|
||||
body = body.toString().toRequestBody(contentType),
|
||||
),
|
||||
)
|
||||
.awaitSuccess()
|
||||
.parseAs<JsonObject>()
|
||||
.let { obj ->
|
||||
try {
|
||||
json.decodeFromJsonElement<Context>(obj["context"]!!)
|
||||
} catch (e: Exception) {
|
||||
// logcat(LogPriority.ERROR, e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import suwayomi.tachidesk.server.generated.BuildConfig
|
||||
import java.io.IOException
|
||||
|
||||
class MangaUpdatesInterceptor(
|
||||
mangaUpdates: MangaUpdates,
|
||||
) : Interceptor {
|
||||
private var token: String? = mangaUpdates.restoreSession()
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
val token = token ?: throw IOException("Not authenticated with MangaUpdates")
|
||||
|
||||
// Add the authorization header to the original request.
|
||||
val authRequest =
|
||||
originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer $token")
|
||||
.header("User-Agent", "Suwayomi ${BuildConfig.VERSION} (${BuildConfig.REVISION})")
|
||||
.build()
|
||||
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
fun newAuth(token: String?) {
|
||||
this.token = token
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Context(
|
||||
@SerialName("session_token")
|
||||
val sessionToken: String,
|
||||
val uid: Long,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Image(
|
||||
val url: Url? = null,
|
||||
val height: Int? = null,
|
||||
val width: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.MangaUpdates.Companion.READING_LIST
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
|
||||
@Serializable
|
||||
data class ListItem(
|
||||
val series: Series? = null,
|
||||
@SerialName("list_id")
|
||||
val listId: Int? = null,
|
||||
val status: Status? = null,
|
||||
val priority: Int? = null,
|
||||
)
|
||||
|
||||
fun ListItem.copyTo(track: Track): Track {
|
||||
return track.apply {
|
||||
this.status = listId ?: READING_LIST
|
||||
this.last_chapter_read = this@copyTo.status?.chapter?.toFloat() ?: 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import suwayomi.tachidesk.manga.impl.track.tracker.model.Track
|
||||
|
||||
@Serializable
|
||||
data class Rating(
|
||||
val rating: Float? = null,
|
||||
)
|
||||
|
||||
fun Rating.copyTo(track: Track): Track {
|
||||
return track.apply {
|
||||
this.score = rating ?: 0f
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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.tracker.model.TrackSearch
|
||||
|
||||
@Serializable
|
||||
data class Record(
|
||||
@SerialName("series_id")
|
||||
val seriesId: Long? = null,
|
||||
val title: String? = null,
|
||||
val url: String? = null,
|
||||
val description: String? = null,
|
||||
val image: Image? = null,
|
||||
val type: String? = null,
|
||||
val year: String? = null,
|
||||
@SerialName("bayesian_rating")
|
||||
val bayesianRating: Double? = null,
|
||||
@SerialName("rating_votes")
|
||||
val ratingVotes: Int? = null,
|
||||
@SerialName("latest_chapter")
|
||||
val latestChapter: Int? = null,
|
||||
)
|
||||
|
||||
private fun String.htmlDecode(): String {
|
||||
return Jsoup.parse(this).wholeText()
|
||||
}
|
||||
|
||||
fun Record.toTrackSearch(id: Int): TrackSearch {
|
||||
return TrackSearch.create(id).apply {
|
||||
media_id = this@toTrackSearch.seriesId ?: 0L
|
||||
title = this@toTrackSearch.title?.htmlDecode() ?: ""
|
||||
total_chapters = 0
|
||||
cover_url = this@toTrackSearch.image?.url?.original ?: ""
|
||||
summary = this@toTrackSearch.description?.htmlDecode() ?: ""
|
||||
tracking_url = this@toTrackSearch.url ?: ""
|
||||
publishing_status = ""
|
||||
publishing_type = this@toTrackSearch.type.toString()
|
||||
start_date = this@toTrackSearch.year.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Series(
|
||||
val id: Long? = null,
|
||||
val title: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Status(
|
||||
val volume: Int? = null,
|
||||
val chapter: Int? = null,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
package suwayomi.tachidesk.manga.impl.track.tracker.mangaupdates.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class Url(
|
||||
val original: String? = null,
|
||||
val thumb: String? = null,
|
||||
)
|
||||
BIN
server/src/main/resources/static/tracker/manga_updates.png
Normal file
BIN
server/src/main/resources/static/tracker/manga_updates.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Reference in New Issue
Block a user