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 0000000000..246a62a7ca Binary files /dev/null and b/app/src/main/res/drawable-nodpi/ic_tracker_suwayomi.webp differ 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