Parcourir la source

add Suwayomi tracker (#8489)

* add Suwayomi Tracker

* fix compile
Aria Moradi il y a 2 ans
Parent
commit
c4c9931ae2

+ 18 - 0
app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt

@@ -186,6 +186,24 @@ object SettingsTrackingScreen : SearchableSettings {
                         },
                         logout = trackManager.kavita::logout,
                     ),
+
+                    Preference.PreferenceItem.TrackingPreference(
+                        title = stringResource(trackManager.suwayomi.nameRes()),
+                        service = trackManager.suwayomi,
+                        login = {
+                            val sourceManager = Injekt.get<SourceManager>()
+                            val acceptedSources = trackManager.suwayomi.getAcceptedSources()
+                            val hasValidSourceInstalled = sourceManager.getCatalogueSources()
+                                .any { it::class.qualifiedName in acceptedSources }
+
+                            if (hasValidSourceInstalled) {
+                                trackManager.suwayomi.loginNoop()
+                            } else {
+                                context.toast(context.getString(R.string.enhanced_tracking_warning, context.getString(trackManager.suwayomi.nameRes())), Toast.LENGTH_LONG)
+                            }
+                        },
+                        logout = trackManager.suwayomi::logout,
+                    ),
                     Preference.PreferenceItem.InfoPreference(stringResource(R.string.enhanced_tracking_info)),
                 ),
             ),

+ 4 - 1
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt

@@ -9,6 +9,7 @@ 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
+import eu.kanade.tachiyomi.data.track.suwayomi.Suwayomi
 
 class TrackManager(context: Context) {
 
@@ -21,6 +22,7 @@ class TrackManager(context: Context) {
         const val KOMGA = 6L
         const val MANGA_UPDATES = 7L
         const val KAVITA = 8L
+        const val SUWAYOMI = 9L
     }
 
     val myAnimeList = MyAnimeList(context, MYANIMELIST)
@@ -31,8 +33,9 @@ class TrackManager(context: Context) {
     val komga = Komga(context, KOMGA)
     val mangaUpdates = MangaUpdates(context, MANGA_UPDATES)
     val kavita = Kavita(context, KAVITA)
+    val suwayomi = Suwayomi(context, SUWAYOMI)
 
-    val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita)
+    val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi)
 
     fun getService(id: Long) = services.find { it.id == id }
 

+ 102 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt

@@ -0,0 +1,102 @@
+package eu.kanade.tachiyomi.data.track.suwayomi
+
+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.EnhancedTrackService
+import eu.kanade.tachiyomi.data.track.NoLoginTrackService
+import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+import eu.kanade.tachiyomi.source.Source
+import eu.kanade.domain.manga.model.Manga as DomainManga
+import eu.kanade.domain.track.model.Track as DomainTrack
+
+class Suwayomi(private val context: Context, id: Long) : TrackService(id), NoLoginTrackService, EnhancedTrackService {
+    val api by lazy { TachideskApi() }
+
+    @StringRes
+    override fun nameRes() = R.string.tracker_suwayomi
+
+    override fun getLogo() = R.drawable.ic_tracker_suwayomi
+
+    override fun getLogoColor() = Color.rgb(255, 35, 35) // TODO
+
+    companion object {
+        const val UNREAD = 1
+        const val READING = 2
+        const val COMPLETED = 3
+    }
+
+    override fun getStatusList() = listOf(UNREAD, READING, COMPLETED)
+
+    override fun getStatus(status: Int): String = with(context) {
+        when (status) {
+            UNREAD -> getString(R.string.unread)
+            READING -> getString(R.string.reading)
+            COMPLETED -> getString(R.string.completed)
+            else -> ""
+        }
+    }
+
+    override fun getReadingStatus(): Int = READING
+
+    override fun getRereadingStatus(): Int = -1
+
+    override fun getCompletionStatus(): Int = COMPLETED
+
+    override fun getScoreList(): List<String> = emptyList()
+
+    override fun displayScore(track: Track): String = ""
+
+    override suspend fun update(track: Track, didReadChapter: Boolean): Track {
+        if (track.status != COMPLETED) {
+            if (didReadChapter) {
+                if (track.last_chapter_read.toInt() == track.total_chapters && track.total_chapters > 0) {
+                    track.status = COMPLETED
+                } else {
+                    track.status = READING
+                }
+            }
+        }
+
+        return api.updateProgress(track)
+    }
+
+    override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
+        return track
+    }
+
+    override suspend fun search(query: String): List<TrackSearch> {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun refresh(track: Track): Track {
+        val remoteTrack = api.getTrackSearch(track.tracking_url)
+        track.copyPersonalFrom(remoteTrack)
+        track.total_chapters = remoteTrack.total_chapters
+        return track
+    }
+
+    override suspend fun login(username: String, password: String) {
+        saveCredentials("user", "pass")
+    }
+
+    override fun loginNoop() {
+        saveCredentials("user", "pass")
+    }
+
+    override fun getAcceptedSources(): List<String> = listOf("eu.kanade.tachiyomi.extension.all.tachidesk.Tachidesk")
+
+    override suspend fun match(manga: DomainManga): TrackSearch = api.getTrackSearch(manga.url)
+
+    override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean = source?.let { accept(it) } == true
+
+    override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? =
+        if (accept(newSource)) {
+            track.copy(remoteUrl = manga.url)
+        } else {
+            null
+        }
+}

+ 113 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/TachideskApi.kt

@@ -0,0 +1,113 @@
+package eu.kanade.tachiyomi.data.track.suwayomi
+
+import android.app.Application
+import android.content.SharedPreferences
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.NetworkHelper
+import eu.kanade.tachiyomi.network.PUT
+import eu.kanade.tachiyomi.network.await
+import eu.kanade.tachiyomi.network.parseAs
+import eu.kanade.tachiyomi.util.lang.withIOContext
+import okhttp3.Credentials
+import okhttp3.Dns
+import okhttp3.FormBody
+import okhttp3.Headers
+import okhttp3.OkHttpClient
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import uy.kohesive.injekt.injectLazy
+import java.nio.charset.Charset
+import java.security.MessageDigest
+
+class TachideskApi {
+    private val network by injectLazy<NetworkHelper>()
+    val client: OkHttpClient =
+        network.client.newBuilder()
+            .dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing
+            .build()
+    fun headersBuilder(): Headers.Builder = Headers.Builder().apply {
+        add("User-Agent", network.defaultUserAgent)
+        if (basePassword.isNotEmpty() && baseLogin.isNotEmpty()) {
+            val credentials = Credentials.basic(baseLogin, basePassword)
+            add("Authorization", credentials)
+        }
+    }
+
+    val headers: Headers by lazy { headersBuilder().build() }
+
+    private val baseUrl by lazy { getPrefBaseUrl() }
+    private val baseLogin by lazy { getPrefBaseLogin() }
+    private val basePassword by lazy { getPrefBasePassword() }
+
+    suspend fun getTrackSearch(trackUrl: String): TrackSearch = withIOContext {
+        val url = try {
+            // test if getting api url or manga id
+            val mangaId = trackUrl.toLong()
+            "$baseUrl/api/v1/manga/$mangaId"
+        } catch (e: NumberFormatException) {
+            trackUrl
+        }
+
+        val manga = client.newCall(GET("$url/full", headers)).await().parseAs<MangaDataClass>()
+
+        TrackSearch.create(TrackManager.SUWAYOMI).apply {
+            title = manga.title
+            cover_url = "$url/thumbnail"
+            summary = manga.description
+            tracking_url = url
+            total_chapters = manga.chapterCount.toInt()
+            publishing_status = manga.status
+            last_chapter_read = manga.lastChapterRead?.chapterNumber ?: 0F
+            status = when (manga.unreadCount) {
+                manga.chapterCount -> Suwayomi.UNREAD
+                0L -> Suwayomi.COMPLETED
+                else -> Suwayomi.READING
+            }
+        }
+    }
+
+    suspend fun updateProgress(track: Track): Track {
+        val url = track.tracking_url
+        val chapters = client.newCall(GET("$url/chapters", headers)).await().parseAs<List<ChapterDataClass>>()
+        val lastChapterIndex = chapters.first { it.chapterNumber == track.last_chapter_read }.index
+
+        client.newCall(
+            PUT(
+                "$url/chapter/$lastChapterIndex",
+                headers,
+                FormBody.Builder(Charset.forName("utf8"))
+                    .add("markPrevRead", "true")
+                    .add("read", "true")
+                    .build(),
+            ),
+        ).await()
+
+        return getTrackSearch(track.tracking_url)
+    }
+
+    val tachideskExtensionId by lazy {
+        val key = "tachidesk/en/1"
+        val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
+        (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
+    }
+
+    private val preferences: SharedPreferences by lazy {
+        Injekt.get<Application>().getSharedPreferences("source_$tachideskExtensionId", 0x0000)
+    }
+
+    companion object {
+        private const val ADDRESS_TITLE = "Server URL Address"
+        private const val ADDRESS_DEFAULT = ""
+        private const val LOGIN_TITLE = "Login (Basic Auth)"
+        private const val LOGIN_DEFAULT = ""
+        private const val PASSWORD_TITLE = "Password (Basic Auth)"
+        private const val PASSWORD_DEFAULT = ""
+    }
+
+    private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
+    private fun getPrefBaseLogin(): String = preferences.getString(LOGIN_TITLE, LOGIN_DEFAULT)!!
+    private fun getPrefBasePassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
+}

+ 97 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/TachideskDto.kt

@@ -0,0 +1,97 @@
+package eu.kanade.tachiyomi.data.track.suwayomi
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SourceDataClass(
+    val id: String,
+    val name: String?,
+    val lang: String?,
+    val iconUrl: String?,
+
+    /** The Source provides a latest listing */
+    val supportsLatest: Boolean?,
+
+    /** The Source implements [ConfigurableSource] */
+    val isConfigurable: Boolean?,
+
+    /** The Source class has a @Nsfw annotation */
+    val isNsfw: Boolean?,
+
+    /** A nicer version of [name] */
+    val displayName: String?,
+)
+
+@Serializable
+data class MangaDataClass(
+    val id: Int,
+    val sourceId: String,
+
+    val url: String,
+    val title: String,
+    val thumbnailUrl: String,
+
+    val initialized: Boolean,
+
+    val artist: String,
+    val author: String,
+    val description: String,
+    val genre: List<String>,
+    val status: String,
+    val inLibrary: Boolean,
+    val inLibraryAt: Long,
+    val source: SourceDataClass,
+
+    val meta: Map<String, String> = emptyMap(),
+
+    val realUrl: String,
+    var lastFetchedAt: Long,
+    var chaptersLastFetchedAt: Long,
+
+    val freshData: Boolean,
+    val unreadCount: Long,
+    val downloadCount: Long,
+    val chapterCount: Long,
+    val lastChapterRead: ChapterDataClass?,
+
+    val age: Long,
+    val chaptersAge: Long,
+)
+
+@Serializable
+data class ChapterDataClass(
+    val id: Int,
+    val url: String,
+    val name: String,
+    val uploadDate: Long,
+    val chapterNumber: Float,
+    val scanlator: String?,
+    val mangaId: Int,
+
+    /** chapter is read */
+    val read: Boolean,
+
+    /** chapter is bookmarked */
+    val bookmarked: Boolean,
+
+    /** last read page, zero means not read/no data */
+    val lastPageRead: Int,
+
+    /** last read page, zero means not read/no data */
+    val lastReadAt: Long,
+
+    /** this chapter's index, starts with 1 */
+    val index: Int,
+
+    /** the date we fist saw this chapter*/
+    val fetchedAt: Long,
+
+    /** is chapter downloaded */
+    val downloaded: Boolean,
+
+    /** used to construct pages in the front-end */
+    val pageCount: Int,
+
+    /** total chapter count, used to calculate if there's a next and prev chapter */
+    val chapterCount: Int,
+)

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


+ 1 - 0
i18n/src/main/res/values/strings.xml

@@ -682,6 +682,7 @@
     <string name="tracker_shikimori" translatable="false">Shikimori</string>
     <string name="tracker_manga_updates" translatable="false">MangaUpdates</string>
     <string name="tracker_kavita" translatable="false">Kavita</string>
+    <string name="tracker_suwayomi" translatable="false">Suwayomi</string>
     <string name="manga_tracking_tab">Tracking</string>
     <plurals name="num_trackers">
         <item quantity="one">%d tracker</item>