add Tachidesk extension (#8361)
* add Tachidesk extension * rewrite with kotlinx-serialization * apply suggestions by @ObserverOfTime * linter must lint * map Tachidesk MangaStatus to SManga constants * raise exception when baseUrl is empty * defensive programming * print Exception to Log.e instead * cesco asked for this...
This commit is contained in:
parent
958cc4457c
commit
94915eeb0f
|
@ -0,0 +1,2 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest package="eu.kanade.tachiyomi.extension" />
|
|
@ -0,0 +1,14 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
extName = 'Tachidesk'
|
||||||
|
pkgNameSuffix = 'all.tachidesk'
|
||||||
|
extClass = '.Tachidesk'
|
||||||
|
extVersionCode = 1
|
||||||
|
libVersion = '1.2'
|
||||||
|
containsNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 46 KiB |
|
@ -0,0 +1,73 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.tachidesk
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SourceDataClass(
|
||||||
|
val id: String,
|
||||||
|
val name: String?,
|
||||||
|
val lang: String?,
|
||||||
|
val iconUrl: String?,
|
||||||
|
val supportsLatest: Boolean?,
|
||||||
|
val isConfigurable: Boolean?
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class MangaDataClass(
|
||||||
|
val id: Int,
|
||||||
|
val sourceId: String,
|
||||||
|
|
||||||
|
val url: String,
|
||||||
|
val title: String,
|
||||||
|
val thumbnailUrl: String? = null,
|
||||||
|
|
||||||
|
val initialized: Boolean = false,
|
||||||
|
|
||||||
|
val artist: String? = null,
|
||||||
|
val author: String? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val genre: String? = null,
|
||||||
|
val status: String = "UNKNOWN",
|
||||||
|
val inLibrary: Boolean = false,
|
||||||
|
val source: SourceDataClass? = null,
|
||||||
|
val meta: Map<String, String> = emptyMap(),
|
||||||
|
|
||||||
|
val freshData: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChapterDataClass(
|
||||||
|
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,
|
||||||
|
|
||||||
|
/** is chapter downloaded */
|
||||||
|
val downloaded: Boolean,
|
||||||
|
|
||||||
|
/** used to construct pages in the front-end */
|
||||||
|
val pageCount: Int = -1,
|
||||||
|
|
||||||
|
/** total chapter count, used to calculate if there's a next and prev chapter */
|
||||||
|
val chapterCount: Int? = null,
|
||||||
|
|
||||||
|
/** used to store client specific values */
|
||||||
|
val meta: Map<String, String> = emptyMap(),
|
||||||
|
)
|
|
@ -0,0 +1,180 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.all.tachidesk
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.text.InputType
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||||
|
import eu.kanade.tachiyomi.source.model.FilterList
|
||||||
|
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||||
|
import eu.kanade.tachiyomi.source.model.Page
|
||||||
|
import eu.kanade.tachiyomi.source.model.SChapter
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import uy.kohesive.injekt.injectLazy
|
||||||
|
import java.lang.RuntimeException
|
||||||
|
|
||||||
|
class Tachidesk : ConfigurableSource, HttpSource() {
|
||||||
|
override val name = "Tachidesk"
|
||||||
|
override val baseUrl by lazy { getPrefBaseUrl() }
|
||||||
|
override val lang = "en"
|
||||||
|
override val supportsLatest = false
|
||||||
|
|
||||||
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
|
// ------------- Popular Manga -------------
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request =
|
||||||
|
GET("$checkedBaseUrl/api/v1/library")
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage =
|
||||||
|
MangasPage(
|
||||||
|
json.decodeFromString<List<MangaDataClass>>(response.body!!.string()).map {
|
||||||
|
it.toSManga()
|
||||||
|
},
|
||||||
|
false
|
||||||
|
)
|
||||||
|
// ------------- Manga Details -------------
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga) =
|
||||||
|
GET("$checkedBaseUrl/api/v1/manga/${manga.url}/?onlineFetch=true")
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga =
|
||||||
|
json.decodeFromString<MangaDataClass>(response.body!!.string()).let { it.toSManga() }
|
||||||
|
|
||||||
|
// ------------- Chapter -------------
|
||||||
|
|
||||||
|
override fun chapterListRequest(manga: SManga): Request =
|
||||||
|
GET("$checkedBaseUrl/api/v1/manga/${manga.url}/chapters?onlineFetch=true", headers)
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> =
|
||||||
|
json.decodeFromString<List<ChapterDataClass>>(response.body!!.string()).map {
|
||||||
|
it.toSChapter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------- Page List -------------
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
return client.newCall(pageListRequest(chapter))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
pageListParse(response, chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
val mangaId = chapter.url.split(" ").first()
|
||||||
|
val chapterIndex = chapter.url.split(" ").last()
|
||||||
|
|
||||||
|
return GET("$checkedBaseUrl/api/v1/manga/$mangaId/chapter/$chapterIndex/?onlineFetch=True", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pageListParse(response: Response, sChapter: SChapter): List<Page> {
|
||||||
|
val mangaId = sChapter.url.split(" ").first()
|
||||||
|
val chapterIndex = sChapter.url.split(" ").last()
|
||||||
|
|
||||||
|
val chapter = json.decodeFromString<ChapterDataClass>(response.body!!.string())
|
||||||
|
|
||||||
|
return List(chapter.pageCount) {
|
||||||
|
Page(it + 1, "", "$checkedBaseUrl/api/v1/manga/$mangaId/chapter/$chapterIndex/page/$it/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------- Preferences -------------
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
screen.addPreference(screen.editTextPreference(ADDRESS_TITLE, ADDRESS_DEFAULT, baseUrl))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** boilerplate for [EditTextPreference] */
|
||||||
|
private fun PreferenceScreen.editTextPreference(title: String, default: String, value: String, isPassword: Boolean = false): EditTextPreference {
|
||||||
|
return EditTextPreference(context).apply {
|
||||||
|
key = title
|
||||||
|
this.title = title
|
||||||
|
summary = value
|
||||||
|
this.setDefaultValue(default)
|
||||||
|
dialogTitle = title
|
||||||
|
|
||||||
|
if (isPassword) {
|
||||||
|
setOnBindEditTextListener {
|
||||||
|
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
try {
|
||||||
|
val res = preferences.edit().putString(title, newValue as String).commit()
|
||||||
|
Toast.makeText(context, "Restart Tachiyomi to apply new setting.", Toast.LENGTH_LONG).show()
|
||||||
|
res
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("Tachidesk", "Exception while setting text preference", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val preferences: SharedPreferences by lazy {
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ADDRESS_TITLE = "Address"
|
||||||
|
private const val ADDRESS_DEFAULT = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------- Not Used -------------
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request = throw Exception("Not used")
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage = throw Exception("Not used")
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw Exception("Not used")
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage = throw Exception("Not used")
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> = throw Exception("Not used")
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String = throw Exception("Not used")
|
||||||
|
|
||||||
|
// ------------- Util -------------
|
||||||
|
|
||||||
|
private fun MangaDataClass.toSManga() = SManga.create().also {
|
||||||
|
it.title = title
|
||||||
|
it.url = id.toString()
|
||||||
|
it.thumbnail_url = "$baseUrl$thumbnailUrl"
|
||||||
|
it.artist = artist
|
||||||
|
it.author = author
|
||||||
|
it.description = description
|
||||||
|
it.status = when (status) {
|
||||||
|
"ONGOING" -> SManga.ONGOING
|
||||||
|
"COMPLETED" -> SManga.COMPLETED
|
||||||
|
"LICENSED" -> SManga.LICENSED
|
||||||
|
else -> SManga.UNKNOWN // covers "UNKNOWN" and other Impossible cases
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ChapterDataClass.toSChapter() = SChapter.create().also {
|
||||||
|
it.url = "$mangaId $index"
|
||||||
|
it.name = name
|
||||||
|
it.date_upload = uploadDate
|
||||||
|
it.scanlator = scanlator
|
||||||
|
}
|
||||||
|
|
||||||
|
private val checkedBaseUrl: String
|
||||||
|
get(): String = if (baseUrl.isNotEmpty()) baseUrl
|
||||||
|
else throw RuntimeException("Set Tachidesk server url in extension settings")
|
||||||
|
}
|
Loading…
Reference in New Issue