From c4c9931ae22c92429eb5afcef06ed2ea3a1cbb8f Mon Sep 17 00:00:00 2001 From: Aria Moradi Date: Sat, 7 Jan 2023 22:57:44 +0330 Subject: [PATCH] add Suwayomi tracker (#8489) * add Suwayomi Tracker * fix compile --- .../settings/screen/SettingsTrackingScreen.kt | 18 +++ .../tachiyomi/data/track/TrackManager.kt | 5 +- .../tachiyomi/data/track/suwayomi/Suwayomi.kt | 102 ++++++++++++++++ .../data/track/suwayomi/TachideskApi.kt | 113 ++++++++++++++++++ .../data/track/suwayomi/TachideskDto.kt | 97 +++++++++++++++ .../drawable-nodpi/ic_tracker_suwayomi.webp | Bin 0 -> 12808 bytes i18n/src/main/res/values/strings.xml | 1 + 7 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/TachideskApi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/TachideskDto.kt create mode 100644 app/src/main/res/drawable-nodpi/ic_tracker_suwayomi.webp diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt index a94ce45c4d..c7c808fbe4 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt +++ b/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() + 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)), ), ), 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 ac78970b8a..10b396f447 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 @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/Suwayomi.kt new file mode 100644 index 0000000000..111d43ade1 --- /dev/null +++ b/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 = 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 { + 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 = 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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/TachideskApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/TachideskApi.kt new file mode 100644 index 0000000000..73af958c90 --- /dev/null +++ b/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() + 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() + + 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>() + 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().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)!! +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/TachideskDto.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/suwayomi/TachideskDto.kt new file mode 100644 index 0000000000..5fae55d0c4 --- /dev/null +++ b/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, + val status: String, + val inLibrary: Boolean, + val inLibraryAt: Long, + val source: SourceDataClass, + + val meta: Map = 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, +) diff --git a/app/src/main/res/drawable-nodpi/ic_tracker_suwayomi.webp b/app/src/main/res/drawable-nodpi/ic_tracker_suwayomi.webp new file mode 100644 index 0000000000000000000000000000000000000000..246a62a7ca4a6638427f11a00f4253f0b32853f4 GIT binary patch literal 12808 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 BCQ<+Z literal 0 HcmV?d00001 diff --git a/i18n/src/main/res/values/strings.xml b/i18n/src/main/res/values/strings.xml index a0c7859550..f9becf36cb 100644 --- a/i18n/src/main/res/values/strings.xml +++ b/i18n/src/main/res/values/strings.xml @@ -682,6 +682,7 @@ Shikimori MangaUpdates Kavita + Suwayomi Tracking %d tracker