diff --git a/server/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt b/server/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt index 39f84967..67a6671b 100644 --- a/server/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt +++ b/server/src/main/kotlin/eu/kanade/tachiyomi/network/Requests.kt @@ -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() +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt index cea4a182..feab34a3 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerManager.kt @@ -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 = listOf(myAnimeList, aniList) + val services: List = listOf(myAnimeList, aniList, mangaUpdates) fun getTracker(id: Int) = services.find { it.id == id } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt new file mode 100644 index 00000000..9e15eb36 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdates.kt @@ -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 { + 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 = 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 { + 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) + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesApi.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesApi.kt new file mode 100644 index 00000000..8ecc132f --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesApi.kt @@ -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 { + val listItem = + with(json) { + authClient.newCall(GET("$baseUrl/v1/lists/series/${track.media_id}")) + .awaitSuccess() + .parseAs() + } + + 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() + } + } 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 { + 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() + .let { obj -> + obj["results"]?.jsonArray?.map { element -> + json.decodeFromJsonElement(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() + .let { obj -> + try { + json.decodeFromJsonElement(obj["context"]!!) + } catch (e: Exception) { + // logcat(LogPriority.ERROR, e) + null + } + } + } + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt new file mode 100644 index 00000000..ce2cb8c7 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/MangaUpdatesInterceptor.kt @@ -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 + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Context.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Context.kt new file mode 100644 index 00000000..9c5690a6 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Context.kt @@ -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, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Image.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Image.kt new file mode 100644 index 00000000..907564fe --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Image.kt @@ -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, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/ListItem.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/ListItem.kt new file mode 100644 index 00000000..e030b59a --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/ListItem.kt @@ -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 + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Rating.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Rating.kt new file mode 100644 index 00000000..78851715 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Rating.kt @@ -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 + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Record.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Record.kt new file mode 100644 index 00000000..cc31b1e3 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Record.kt @@ -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() + } +} diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Series.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Series.kt new file mode 100644 index 00000000..ffd6ec4d --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Series.kt @@ -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, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Status.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Status.kt new file mode 100644 index 00000000..3d5ed016 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Status.kt @@ -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, +) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Url.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Url.kt new file mode 100644 index 00000000..876db261 --- /dev/null +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/mangaupdates/dto/Url.kt @@ -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, +) diff --git a/server/src/main/resources/static/tracker/manga_updates.png b/server/src/main/resources/static/tracker/manga_updates.png new file mode 100644 index 00000000..fb593493 Binary files /dev/null and b/server/src/main/resources/static/tracker/manga_updates.png differ