From 0c631a499025aee66354caec690b68cea4dedfdc Mon Sep 17 00:00:00 2001 From: Andreas Date: Wed, 25 May 2022 00:00:33 +0200 Subject: [PATCH] Add MangaUpdates as a tracker (#7170) * Add MangaUpdates as a tracker - jobobby04 co-authored for suggestion in BackupTracking.kt Co-authored-by: jobobby04 * Changes from code review Co-authored-by: arkon Co-authored-by: jobobby04 Co-authored-by: arkon --- .../data/backup/full/models/BackupTracking.kt | 10 +- .../legacy/serializer/TrackTypeSerializer.kt | 2 +- .../data/database/mappers/TrackTypeMapping.kt | 2 +- .../tachiyomi/data/database/models/Track.kt | 2 +- .../data/database/models/TrackImpl.kt | 4 +- .../tachiyomi/data/track/TrackManager.kt | 6 +- .../data/track/anilist/AnilistApi.kt | 4 +- .../data/track/anilist/AnilistModels.kt | 2 +- .../data/track/bangumi/BangumiApi.kt | 3 +- .../tachiyomi/data/track/kitsu/KitsuApi.kt | 6 +- .../tachiyomi/data/track/kitsu/KitsuModels.kt | 5 +- .../data/track/mangaupdates/MangaUpdates.kt | 97 +++++++++ .../track/mangaupdates/MangaUpdatesApi.kt | 189 ++++++++++++++++++ .../mangaupdates/MangaUpdatesInterceptor.kt | 29 +++ .../data/track/mangaupdates/dto/Context.kt | 11 + .../data/track/mangaupdates/dto/Image.kt | 10 + .../data/track/mangaupdates/dto/ListItem.kt | 22 ++ .../data/track/mangaupdates/dto/Rating.kt | 15 ++ .../data/track/mangaupdates/dto/Record.kt | 37 ++++ .../data/track/mangaupdates/dto/Series.kt | 9 + .../data/track/mangaupdates/dto/Status.kt | 9 + .../data/track/mangaupdates/dto/Url.kt | 9 + .../tachiyomi/data/track/model/TrackSearch.kt | 4 +- .../data/track/myanimelist/MyAnimeListApi.kt | 5 +- .../data/track/shikimori/ShikimoriApi.kt | 5 +- .../eu/kanade/tachiyomi/network/Requests.kt | 28 +++ .../ui/setting/SettingsTrackingController.kt | 6 +- .../res/drawable-nodpi/ic_manga_updates.webp | Bin 0 -> 11178 bytes app/src/main/res/values/strings.xml | 6 + 29 files changed, 513 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesInterceptor.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt create mode 100644 app/src/main/res/drawable-nodpi/ic_manga_updates.webp diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt index 2ef022d5d5..fbd6a55003 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt @@ -12,7 +12,7 @@ data class BackupTracking( @ProtoNumber(1) var syncId: Int, // LibraryId is not null in 1.x @ProtoNumber(2) var libraryId: Long, - @ProtoNumber(3) var mediaId: Int = 0, + @Deprecated("Use mediaId instead", level = DeprecationLevel.WARNING) @ProtoNumber(3) var mediaIdInt: Int = 0, // trackingUrl is called mediaUrl in 1.x @ProtoNumber(4) var trackingUrl: String = "", @ProtoNumber(5) var title: String = "", @@ -25,11 +25,17 @@ data class BackupTracking( @ProtoNumber(10) var startedReadingDate: Long = 0, // finishedReadingDate is called endReadTime in 1.x @ProtoNumber(11) var finishedReadingDate: Long = 0, + @ProtoNumber(100) var mediaId: Long = 0, ) { + fun getTrackingImpl(): TrackImpl { return TrackImpl().apply { sync_id = this@BackupTracking.syncId - media_id = this@BackupTracking.mediaId + media_id = if (this@BackupTracking.mediaIdInt != 0) { + this@BackupTracking.mediaIdInt.toLong() + } else { + this@BackupTracking.mediaId + } library_id = this@BackupTracking.libraryId title = this@BackupTracking.title last_chapter_read = this@BackupTracking.lastChapterRead diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt index 4691e9a868..1d2ced7ebe 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt @@ -45,7 +45,7 @@ open class TrackBaseSerializer : KSerializer { val jsonObject = decoder.decodeJsonElement().jsonObject title = jsonObject[TITLE]!!.jsonPrimitive.content sync_id = jsonObject[SYNC]!!.jsonPrimitive.int - media_id = jsonObject[MEDIA]!!.jsonPrimitive.int + media_id = jsonObject[MEDIA]!!.jsonPrimitive.long library_id = jsonObject[LIBRARY]!!.jsonPrimitive.long last_chapter_read = jsonObject[LAST_READ]!!.jsonPrimitive.float tracking_url = jsonObject[TRACKING_URL]!!.jsonPrimitive.content diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt index 24ca7c26e8..764f2325af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt @@ -68,7 +68,7 @@ class TrackGetResolver : DefaultGetResolver() { id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_ID)) manga_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_MANGA_ID)) sync_id = cursor.getInt(cursor.getColumnIndexOrThrow(COL_SYNC_ID)) - media_id = cursor.getInt(cursor.getColumnIndexOrThrow(COL_MEDIA_ID)) + media_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_MEDIA_ID)) library_id = cursor.getLong(cursor.getColumnIndexOrThrow(COL_LIBRARY_ID)) title = cursor.getString(cursor.getColumnIndexOrThrow(COL_TITLE)) last_chapter_read = cursor.getFloat(cursor.getColumnIndexOrThrow(COL_LAST_CHAPTER_READ)) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt index b577451af5..4fb22b91a1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt @@ -10,7 +10,7 @@ interface Track : Serializable { var sync_id: Int - var media_id: Int + var media_id: Long var library_id: Long? diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt index 082769b2d6..b447ef56af 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt @@ -8,7 +8,7 @@ class TrackImpl : Track { override var sync_id: Int = 0 - override var media_id: Int = 0 + override var media_id: Long = 0 override var library_id: Long? = null @@ -42,7 +42,7 @@ class TrackImpl : Track { override fun hashCode(): Int { var result = (manga_id xor manga_id.ushr(32)).toInt() result = 31 * result + sync_id - result = 31 * result + media_id + result = 31 * result + media_id.toInt() return result } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt index 05065920c2..584136bd22 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt @@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.bangumi.Bangumi import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.komga.Komga +import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList import eu.kanade.tachiyomi.data.track.shikimori.Shikimori @@ -17,6 +18,7 @@ class TrackManager(context: Context) { const val SHIKIMORI = 4 const val BANGUMI = 5 const val KOMGA = 6 + const val MANGA_UPDATES = 7 } val myAnimeList = MyAnimeList(context, MYANIMELIST) @@ -31,7 +33,9 @@ class TrackManager(context: Context) { val komga = Komga(context, KOMGA) - val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga) + val mangaUpdates = MangaUpdates(context, MANGA_UPDATES) + + val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates) fun getService(id: Int) = services.find { it.id == id } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt index 035ddc1ff9..eb660b5de6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt @@ -268,7 +268,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { private fun jsonToALManga(struct: JsonObject): ALManga { return ALManga( - struct["id"]!!.jsonPrimitive.int, + struct["id"]!!.jsonPrimitive.long, struct["title"]!!.jsonObject["userPreferred"]!!.jsonPrimitive.content, struct["coverImage"]!!.jsonObject["large"]!!.jsonPrimitive.content, struct["description"]!!.jsonPrimitive.contentOrNull, @@ -329,7 +329,7 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) { private const val baseUrl = "https://anilist.co/api/v2/" private const val baseMangaUrl = "https://anilist.co/manga/" - fun mangaUrl(mediaId: Int): String { + fun mangaUrl(mediaId: Long): String { return baseMangaUrl + mediaId } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt index 41cd2fe8ae..120cdc027b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt @@ -9,7 +9,7 @@ import java.text.SimpleDateFormat import java.util.Locale data class ALManga( - val media_id: Int, + val media_id: Long, val title_user_pref: String, val image_url_lge: String, val description: String?, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt index dc299b3ebb..c2a6d9eb85 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -18,6 +18,7 @@ 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.CacheControl import okhttp3.FormBody import okhttp3.OkHttpClient @@ -106,7 +107,7 @@ class BangumiApi(private val client: OkHttpClient, interceptor: BangumiIntercept 0 } return TrackSearch.create(TrackManager.BANGUMI).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["name_cn"]!!.jsonPrimitive.content cover_url = coverUrl summary = obj["name"]!!.jsonPrimitive.content diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index 1387d9d01f..7bb088280c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -11,10 +11,10 @@ import eu.kanade.tachiyomi.network.parseAs import eu.kanade.tachiyomi.util.lang.withIOContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject -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 kotlinx.serialization.json.put import kotlinx.serialization.json.putJsonObject import okhttp3.FormBody @@ -70,7 +70,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) .await() .parseAs() .let { - track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.int + track.media_id = it["data"]!!.jsonObject["id"]!!.jsonPrimitive.long track } } @@ -241,7 +241,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) private const val algoliaFilter = "&facetFilters=%5B%22kind%3Amanga%22%5D&attributesToRetrieve=%5B%22synopsis%22%2C%22canonicalTitle%22%2C%22chapterCount%22%2C%22posterImage%22%2C%22startDate%22%2C%22subtype%22%2C%22endDate%22%2C%20%22id%22%5D" - fun mangaUrl(remoteId: Int): String { + fun mangaUrl(remoteId: Long): String { return baseMangaUrl + remoteId } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt index 1d60178325..453e65ce7a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt @@ -10,12 +10,13 @@ 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 java.text.SimpleDateFormat import java.util.Date import java.util.Locale class KitsuSearchManga(obj: JsonObject) { - val id = obj["id"]!!.jsonPrimitive.int + 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 @@ -60,7 +61,7 @@ class KitsuLibManga(obj: JsonObject, manga: JsonObject) { 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.int + 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 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt new file mode 100644 index 0000000000..8fce9b705b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt @@ -0,0 +1,97 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates + +import android.content.Context +import android.graphics.Color +import androidx.annotation.StringRes +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.copyTo +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch +import eu.kanade.tachiyomi.data.track.model.TrackSearch + +class MangaUpdates(private val context: Context, id: Int) : TrackService(id) { + + 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 interceptor by lazy { MangaUpdatesInterceptor(this) } + + private val api by lazy { MangaUpdatesApi(interceptor, client) } + + @StringRes + override fun nameRes(): Int = R.string.tracker_manga_updates + + override fun getLogo(): Int = R.drawable.ic_manga_updates + + override fun getLogoColor(): Int = Color.rgb(146, 160, 173) + + override fun getStatusList(): List { + return listOf(READING_LIST, COMPLETE_LIST, ON_HOLD_LIST, UNFINISHED_LIST, WISH_LIST) + } + + override fun getStatus(status: Int): String = with(context) { + when (status) { + READING_LIST -> getString(R.string.reading_list) + WISH_LIST -> getString(R.string.wish_list) + COMPLETE_LIST -> getString(R.string.complete_list) + ON_HOLD_LIST -> getString(R.string.on_hold_list) + UNFINISHED_LIST -> getString(R.string.unfinished_list) + else -> "" + } + } + + override fun getReadingStatus(): Int = READING_LIST + + override fun getRereadingStatus(): Int = -1 + + override fun getCompletionStatus(): Int = COMPLETE_LIST + + override fun getScoreList(): List = (0..10).map(Int::toString) + + override fun displayScore(track: Track): String = track.score.toInt().toString() + + override suspend fun update(track: Track, didReadChapter: Boolean): Track { + api.updateSeriesListItem(track) + return track + } + + override suspend fun bind(track: Track, hasReadChapters: Boolean): Track { + return try { + val (series, rating) = api.getSeriesListItem(track) + series.copyTo(track) + rating?.copyTo(track) ?: track + } catch (e: Exception) { + 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) + series.copyTo(track) + return rating?.copyTo(track) ?: track + } + + 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 preferences.trackPassword(this) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt new file mode 100644 index 0000000000..d23b308a6a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt @@ -0,0 +1,189 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST +import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.WISH_LIST +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Context +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.ListItem +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Rating +import eu.kanade.tachiyomi.data.track.mangaupdates.dto.Record +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.await +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.util.system.logcat +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +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 logcat.LogPriority +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.RequestBody.Companion.toRequestBody +import uy.kohesive.injekt.injectLazy + +class MangaUpdatesApi( + interceptor: MangaUpdatesInterceptor, + private val client: OkHttpClient, +) { + private val baseUrl = "https://api.mangaupdates.com" + private val contentType = "application/vnd.api+json".toMediaType() + + private val json by injectLazy() + + private val authClient by lazy { + client.newBuilder() + .addInterceptor(interceptor) + .build() + } + + suspend fun getSeriesListItem(track: Track): Pair { + val listItem = + authClient.newCall( + GET( + url = "$baseUrl/v1/lists/series/${track.media_id}", + ), + ) + .await() + .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), + ), + ) + .await() + .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), + ), + ) + .await() + + updateSeriesRating(track) + } + + suspend fun getSeriesRating(track: Track): Rating? { + return try { + authClient.newCall( + GET( + url = "$baseUrl/v1/series/${track.media_id}/rating", + ), + ) + .await() + .parseAs() + } catch (e: Exception) { + null + } + } + + suspend fun updateSeriesRating(track: Track) { + if (track.score != 0f) { + val body = buildJsonObject { + put("rating", track.score.toInt()) + } + authClient.newCall( + PUT( + url = "$baseUrl/v1/series/${track.media_id}/rating", + body = body.toString().toRequestBody(contentType), + ), + ) + .await() + } else { + authClient.newCall( + DELETE( + url = "$baseUrl/v1/series/${track.media_id}/rating", + ), + ) + .await() + } + } + + suspend fun search(query: String): List { + val body = buildJsonObject { + put("search", query) + } + return client.newCall( + POST( + url = "$baseUrl/v1/series/search", + body = body.toString().toRequestBody(contentType), + ), + ) + .await() + .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 client.newCall( + PUT( + url = "$baseUrl/v1/account/login", + body = body.toString().toRequestBody(contentType), + ), + ) + .await() + .parseAs() + .let { obj -> + try { + json.decodeFromJsonElement(obj["context"]!!) + } catch (e: Exception) { + logcat(LogPriority.ERROR, e) + null + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesInterceptor.kt new file mode 100644 index 0000000000..2b283c3b83 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesInterceptor.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates + +import okhttp3.Interceptor +import okhttp3.Response +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") + .build() + + return chain.proceed(authRequest) + } + + fun newAuth(token: String?) { + this.token = token + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt new file mode 100644 index 0000000000..77019cacd2 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.data.track.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/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt new file mode 100644 index 0000000000..bed1f2657b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.data.track.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/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt new file mode 100644 index 0000000000..4ed8bd7059 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates.Companion.READING_LIST +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@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/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt new file mode 100644 index 0000000000..0de945dd35 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import eu.kanade.tachiyomi.data.database.models.Track +import kotlinx.serialization.Serializable + +@Serializable +data class Rating( + val rating: Int? = null, +) + +fun Rating.copyTo(track: Track): Track { + return track.apply { + this.score = rating?.toFloat() ?: 0f + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt new file mode 100644 index 0000000000..6790290aa1 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@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, +) + +fun Record.toTrackSearch(id: Int): TrackSearch { + return TrackSearch.create(id).apply { + media_id = this@toTrackSearch.seriesId ?: 0L + title = this@toTrackSearch.title ?: "" + total_chapters = 0 + cover_url = this@toTrackSearch.image?.url?.original ?: "" + summary = this@toTrackSearch.description ?: "" + tracking_url = this@toTrackSearch.url ?: "" + publishing_status = "" + publishing_type = this@toTrackSearch.type.toString() + start_date = this@toTrackSearch.year.toString() + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt new file mode 100644 index 0000000000..261c857372 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Series( + val id: Long? = null, + val title: String? = null, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt new file mode 100644 index 0000000000..7320ac2e3d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Status( + val volume: Int? = null, + val chapter: Int? = null, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt new file mode 100644 index 0000000000..f295d3bdc7 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.data.track.mangaupdates.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class Url( + val original: String? = null, + val thumb: String? = null, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt index 90c689b0d4..1dab62211e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt @@ -10,7 +10,7 @@ class TrackSearch : Track { override var sync_id: Int = 0 - override var media_id: Int = 0 + override var media_id: Long = 0 override var library_id: Long? = null @@ -54,7 +54,7 @@ class TrackSearch : Track { override fun hashCode(): Int { var result = (manga_id xor manga_id.ushr(32)).toInt() result = 31 * result + sync_id - result = 31 * result + media_id + result = 31 * result + media_id.toInt() return result } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt index 5eef11d576..22bd32228f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt @@ -21,6 +21,7 @@ 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.OkHttpClient import okhttp3.Request @@ -94,7 +95,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI .let { val obj = it.jsonObject TrackSearch.create(TrackManager.MYANIMELIST).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["title"]!!.jsonPrimitive.content summary = obj["synopsis"]?.jsonPrimitive?.content ?: "" total_chapters = obj["num_chapters"]!!.jsonPrimitive.int @@ -251,7 +252,7 @@ class MyAnimeListApi(private val client: OkHttpClient, interceptor: MyAnimeListI .appendQueryParameter("response_type", "code") .build() - fun mangaUrl(id: Int): Uri = "$baseApiUrl/manga".toUri().buildUpon() + fun mangaUrl(id: Long): Uri = "$baseApiUrl/manga".toUri().buildUpon() .appendPath(id.toString()) .appendPath("my_list_status") .build() diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt index 1aeb598eb3..0319c1cdaa 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt @@ -19,6 +19,7 @@ import kotlinx.serialization.json.float import kotlinx.serialization.json.int 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 @@ -73,7 +74,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter private fun jsonToSearch(obj: JsonObject): TrackSearch { return TrackSearch.create(TrackManager.SHIKIMORI).apply { - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long title = obj["name"]!!.jsonPrimitive.content total_chapters = obj["chapters"]!!.jsonPrimitive.int cover_url = baseUrl + obj["image"]!!.jsonObject["preview"]!!.jsonPrimitive.content @@ -88,7 +89,7 @@ class ShikimoriApi(private val client: OkHttpClient, interceptor: ShikimoriInter private fun jsonToTrack(obj: JsonObject, mangas: JsonObject): Track { return Track.create(TrackManager.SHIKIMORI).apply { title = mangas["name"]!!.jsonPrimitive.content - media_id = obj["id"]!!.jsonPrimitive.int + media_id = obj["id"]!!.jsonPrimitive.long total_chapters = mangas["chapters"]!!.jsonPrimitive.int last_chapter_read = obj["chapters"]!!.jsonPrimitive.float score = (obj["score"]!!.jsonPrimitive.int).toFloat() diff --git a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt index 8931b90b9e..8fb5ec2aa8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt @@ -36,3 +36,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/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index f48068dad0..5c3505d9ca 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -63,13 +63,17 @@ class SettingsTrackingController : dialog.targetController = this@SettingsTrackingController dialog.showDialog(router) } + trackPreference(trackManager.mangaUpdates) { + val dialog = TrackLoginDialog(trackManager.mangaUpdates, R.string.username) + dialog.targetController = this@SettingsTrackingController + dialog.showDialog(router) + } trackPreference(trackManager.shikimori) { activity?.openInBrowser(ShikimoriApi.authUrl(), forceDefaultBrowser = true) } trackPreference(trackManager.bangumi) { activity?.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) } - infoPreference(R.string.tracking_info) } diff --git a/app/src/main/res/drawable-nodpi/ic_manga_updates.webp b/app/src/main/res/drawable-nodpi/ic_manga_updates.webp new file mode 100644 index 0000000000000000000000000000000000000000..eece5d7d65733eb7e6d49f3548b858266dbe1b58 GIT binary patch literal 11178 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 tracker is only compatible with the Komga source. Bangumi Shikimori + MangaUpdates Tracking %d tracker @@ -657,6 +658,11 @@ Paused Plan to read Rereading + Reading List + Wish List + Complete List + On Hold List + Unfinished List Score Title Status