Эх сурвалжийг харах

Add MangaUpdates as a tracker (#7170)

* Add MangaUpdates as a tracker

- jobobby04 co-authored for suggestion in BackupTracking.kt

Co-authored-by: jobobby04 <[email protected]>

* Changes from code review

Co-authored-by: arkon <[email protected]>

Co-authored-by: jobobby04 <[email protected]>
Co-authored-by: arkon <[email protected]>
Andreas 2 жил өмнө
parent
commit
0c631a4990
29 өөрчлөгдсөн 513 нэмэгдсэн , 24 устгасан
  1. 8 2
      app/src/main/java/eu/kanade/tachiyomi/data/backup/full/models/BackupTracking.kt
  2. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt
  3. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt
  4. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/Track.kt
  5. 2 2
      app/src/main/java/eu/kanade/tachiyomi/data/database/models/TrackImpl.kt
  6. 5 1
      app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt
  7. 2 2
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistApi.kt
  8. 1 1
      app/src/main/java/eu/kanade/tachiyomi/data/track/anilist/AnilistModels.kt
  9. 2 1
      app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt
  10. 3 3
      app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt
  11. 3 2
      app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuModels.kt
  12. 97 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdates.kt
  13. 189 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesApi.kt
  14. 29 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/MangaUpdatesInterceptor.kt
  15. 11 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Context.kt
  16. 10 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Image.kt
  17. 22 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/ListItem.kt
  18. 15 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Rating.kt
  19. 37 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Record.kt
  20. 9 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Series.kt
  21. 9 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Status.kt
  22. 9 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/mangaupdates/dto/Url.kt
  23. 2 2
      app/src/main/java/eu/kanade/tachiyomi/data/track/model/TrackSearch.kt
  24. 3 2
      app/src/main/java/eu/kanade/tachiyomi/data/track/myanimelist/MyAnimeListApi.kt
  25. 3 2
      app/src/main/java/eu/kanade/tachiyomi/data/track/shikimori/ShikimoriApi.kt
  26. 28 0
      app/src/main/java/eu/kanade/tachiyomi/network/Requests.kt
  27. 5 1
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt
  28. BIN
      app/src/main/res/drawable-nodpi/ic_manga_updates.webp
  29. 6 0
      app/src/main/res/values/strings.xml

+ 8 - 2
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 = [email protected]
-            media_id = [email protected]
+            media_id = if ([email protected] != 0) {
+                [email protected]()
+            } else {
+                [email protected]
+            }
             library_id = [email protected]
             title = [email protected]
             last_chapter_read = [email protected]

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/backup/legacy/serializer/TrackTypeSerializer.kt

@@ -45,7 +45,7 @@ open class TrackBaseSerializer<T : Track> : KSerializer<T> {
             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

+ 1 - 1
app/src/main/java/eu/kanade/tachiyomi/data/database/mappers/TrackTypeMapping.kt

@@ -68,7 +68,7 @@ class TrackGetResolver : DefaultGetResolver<Track>() {
         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))

+ 1 - 1
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?
 

+ 2 - 2
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
     }
 }

+ 5 - 1
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 }
 

+ 2 - 2
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
         }
 

+ 1 - 1
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?,

+ 2 - 1
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

+ 3 - 3
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<JsonObject>()
                 .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
         }
 

+ 3 - 2
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

+ 97 - 0
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<Int> {
+        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<String> = (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<TrackSearch> {
+        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)
+    }
+}

+ 189 - 0
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<Json>()
+
+    private val authClient by lazy {
+        client.newBuilder()
+            .addInterceptor(interceptor)
+            .build()
+    }
+
+    suspend fun getSeriesListItem(track: Track): Pair<ListItem, Rating?> {
+        val listItem =
+            authClient.newCall(
+                GET(
+                    url = "$baseUrl/v1/lists/series/${track.media_id}",
+                ),
+            )
+                .await()
+                .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),
+            ),
+        )
+            .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<Rating>()
+        } 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<Record> {
+        val body = buildJsonObject {
+            put("search", query)
+        }
+        return client.newCall(
+            POST(
+                url = "$baseUrl/v1/series/search",
+                body = body.toString().toRequestBody(contentType),
+            ),
+        )
+            .await()
+            .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 client.newCall(
+            PUT(
+                url = "$baseUrl/v1/account/login",
+                body = body.toString().toRequestBody(contentType),
+            ),
+        )
+            .await()
+            .parseAs<JsonObject>()
+            .let { obj ->
+                try {
+                    json.decodeFromJsonElement<Context>(obj["context"]!!)
+                } catch (e: Exception) {
+                    logcat(LogPriority.ERROR, e)
+                    null
+                }
+            }
+    }
+}

+ 29 - 0
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
+    }
+}

+ 11 - 0
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,
+)

+ 10 - 0
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,
+)

+ 22 - 0
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 = [email protected]?.chapter?.toFloat() ?: 0f
+    }
+}

+ 15 - 0
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
+    }
+}

+ 37 - 0
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 = [email protected] ?: 0L
+        title = [email protected] ?: ""
+        total_chapters = 0
+        cover_url = [email protected]?.url?.original ?: ""
+        summary = [email protected] ?: ""
+        tracking_url = [email protected] ?: ""
+        publishing_status = ""
+        publishing_type = [email protected]()
+        start_date = [email protected]()
+    }
+}

+ 9 - 0
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,
+)

+ 9 - 0
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,
+)

+ 9 - 0
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,
+)

+ 2 - 2
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
     }
 

+ 3 - 2
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()

+ 3 - 2
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()

+ 28 - 0
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()
+}

+ 5 - 1
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)
         }
 

BIN
app/src/main/res/drawable-nodpi/ic_manga_updates.webp


+ 6 - 0
app/src/main/res/values/strings.xml

@@ -643,6 +643,7 @@
     <string name="tracker_komga_warning">This tracker is only compatible with the Komga source.</string>
     <string name="tracker_bangumi" translatable="false">Bangumi</string>
     <string name="tracker_shikimori" translatable="false">Shikimori</string>
+    <string name="tracker_manga_updates" translatable="false">MangaUpdates</string>
     <string name="manga_tracking_tab">Tracking</string>
     <plurals name="num_trackers">
         <item quantity="one">%d tracker</item>
@@ -657,6 +658,11 @@
     <string name="paused">Paused</string>
     <string name="plan_to_read">Plan to read</string>
     <string name="repeating">Rereading</string>
+    <string name="reading_list">Reading List</string>
+    <string name="wish_list">Wish List</string>
+    <string name="complete_list">Complete List</string>
+    <string name="on_hold_list">On Hold List</string>
+    <string name="unfinished_list">Unfinished List</string>
     <string name="score">Score</string>
     <string name="title">Title</string>
     <string name="status">Status</string>