Add MangaUpdates (#834)

This commit is contained in:
Mitchell Syer
2024-01-21 11:45:34 -05:00
committed by GitHub
parent 46e1e4c043
commit ce42e89e25
14 changed files with 538 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB