add Suwayomi tracker (#8489)

* add Suwayomi Tracker

* fix compile
This commit is contained in:
Aria Moradi 2023-01-07 22:57:44 +03:30 committed by GitHub
parent 68345e636e
commit c4c9931ae2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 335 additions and 1 deletions

View file

@ -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)),
),
),

View file

@ -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 }

View file

@ -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
}
}

View file

@ -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)!!
}

View file

@ -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,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -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>