SakuraManhwa: Added support for SakuraManhwa source (#9195)
New: Added support for SakuraManhwa source
This commit is contained in:
parent
8e7146ec24
commit
6af9f2d853
8
src/all/sakuramanhwa/build.gradle
Normal file
8
src/all/sakuramanhwa/build.gradle
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
ext {
|
||||||
|
extName = 'SakuraManhwa'
|
||||||
|
extClass = '.SakuraManhwa'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = true
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
BIN
src/all/sakuramanhwa/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
BIN
src/all/sakuramanhwa/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.4 KiB |
BIN
src/all/sakuramanhwa/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 6.8 KiB |
BIN
src/all/sakuramanhwa/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
src/all/sakuramanhwa/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
src/all/sakuramanhwa/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
@ -0,0 +1,93 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.sakuramanhwa
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class ApiMangaInfo(
|
||||||
|
val manga: MangaInfo,
|
||||||
|
val chapters: List<ChapterInfo>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class MangaInfo(
|
||||||
|
val details: MangaDetails,
|
||||||
|
val follows: Int,
|
||||||
|
val views: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class MangaDetails(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val img: String,
|
||||||
|
val description: String?,
|
||||||
|
val language: String,
|
||||||
|
val slug: String,
|
||||||
|
val type: String,
|
||||||
|
val status: String?,
|
||||||
|
val author: String,
|
||||||
|
val rating: Float?,
|
||||||
|
val create_at: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class ChapterInfo(
|
||||||
|
val id: String,
|
||||||
|
val title: String?,
|
||||||
|
val create_at: String,
|
||||||
|
val number: Float,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class ApiChapterInfo(
|
||||||
|
val chapter: PageInfo,
|
||||||
|
val prev_chapter: String?,
|
||||||
|
val next_chapter: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class PageInfo(
|
||||||
|
val id: String,
|
||||||
|
val number: Float,
|
||||||
|
val title: String?,
|
||||||
|
val images: List<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class ApiMangaList(
|
||||||
|
val mangas: List<MangaDetails>,
|
||||||
|
val next_page: Int?,
|
||||||
|
val prev_page: Int?,
|
||||||
|
val max_pages: Int?,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class I18nDictionary(
|
||||||
|
val home: I18nHomeDictionary,
|
||||||
|
val library: I18nLibraryDictionary,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class I18nHomeDictionary(
|
||||||
|
val updates: I18nHomeUpdatesDictionary,
|
||||||
|
val lastUpdatesNormal: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class I18nHomeUpdatesDictionary(
|
||||||
|
val buttons: I18nHomeButtonsDictionary,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class I18nHomeButtonsDictionary(
|
||||||
|
val language: Map<String, String>, // all, spanish, english, chinese, raw
|
||||||
|
val genres: Map<String, String>, // all, mature, normal
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal class I18nLibraryDictionary(
|
||||||
|
val title: String,
|
||||||
|
val search: String,
|
||||||
|
val sort: Map<String, String>, // title, type, rating, date
|
||||||
|
val filter: Map<String, String>, // title, category, language, sortBy
|
||||||
|
)
|
@ -0,0 +1,191 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.sakuramanhwa
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import keiyoushi.utils.toJsonString
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.IOException
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.SecureRandom
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.IvParameterSpec
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
class CryptoHelper(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val secretKey: String,
|
||||||
|
private val encryptKey: String,
|
||||||
|
) : Interceptor {
|
||||||
|
private var client: OkHttpClient? = null
|
||||||
|
fun setClient(c: OkHttpClient) {
|
||||||
|
client = c
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var serverTimeAtGeneration: Long? = null
|
||||||
|
private var localTimeAtGeneration: Long? = null
|
||||||
|
|
||||||
|
private val cacheValidityMillis = 4 * 60 * 1000 // 5min expiration
|
||||||
|
|
||||||
|
private var cachedSignedData: CacheState? = null
|
||||||
|
|
||||||
|
private class CacheState(val cachedSignedData: String, val cachedSigneTime: Long)
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun initServerTime() {
|
||||||
|
if (serverTimeAtGeneration != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val response =
|
||||||
|
client!!.newCall(GET("$baseUrl/v1", Headers.Builder().set("NX", "").build())).execute()
|
||||||
|
val serverTime = response.headers.getDate("Date")?.time
|
||||||
|
?: throw IOException("Uninitialized server date")
|
||||||
|
|
||||||
|
this.localTimeAtGeneration = System.currentTimeMillis()
|
||||||
|
this.serverTimeAtGeneration = serverTime
|
||||||
|
this.cachedSignedData = null
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun generateSigned(): String {
|
||||||
|
if (serverTimeAtGeneration == null) {
|
||||||
|
throw Exception("Uninitialized time")
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentTime = System.currentTimeMillis()
|
||||||
|
if (cachedSignedData != null && currentTime - cachedSignedData!!.cachedSigneTime <= cacheValidityMillis) {
|
||||||
|
return cachedSignedData!!.cachedSignedData
|
||||||
|
}
|
||||||
|
|
||||||
|
val timestamp = getCurrentServerTime(currentTime).toString()
|
||||||
|
|
||||||
|
val dataToHash = mapOf(Pair("timesTamp", timestamp)).toJsonString()
|
||||||
|
val hash = hmacSha256(dataToHash, secretKey)
|
||||||
|
val dataToEncrypt = mapOf(Pair("hash", hash), Pair("timesTamp", timestamp)).toJsonString()
|
||||||
|
|
||||||
|
val encrypted = AESCrypt.encrypt(dataToEncrypt, encryptKey)
|
||||||
|
|
||||||
|
cachedSignedData = CacheState(
|
||||||
|
encrypted,
|
||||||
|
currentTime,
|
||||||
|
)
|
||||||
|
return cachedSignedData!!.cachedSignedData
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hmacSha256(data: String, key: String): String {
|
||||||
|
val secretKeySpec = SecretKeySpec(key.toByteArray(), "HmacSHA256")
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(secretKeySpec)
|
||||||
|
val hashBytes = mac.doFinal(data.toByteArray())
|
||||||
|
return hashBytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCurrentServerTime(currentLocalTime: Long): Long {
|
||||||
|
val elapsedLocalMillis = currentLocalTime - localTimeAtGeneration!!
|
||||||
|
return serverTimeAtGeneration!! + elapsedLocalMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
|
var request = chain.request()
|
||||||
|
if (request.headers["NX"] == null && serverTimeAtGeneration == null) {
|
||||||
|
initServerTime()
|
||||||
|
}
|
||||||
|
if (request.headers["NX"] == null && request.url.toString().startsWith(baseUrl)) {
|
||||||
|
request = request.newBuilder().apply {
|
||||||
|
header("Referer", "$baseUrl/")
|
||||||
|
header("St-soon", generateSigned())
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
return chain.proceed(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object AESCrypt {
|
||||||
|
private const val SALTED_PREFIX = "Salted__"
|
||||||
|
private const val KEY_SIZE = 32
|
||||||
|
private const val IV_SIZE = 16
|
||||||
|
private const val SALT_SIZE = 8
|
||||||
|
private const val MD5_ALGORITHM = "MD5"
|
||||||
|
private const val AES_ALGORITHM = "AES"
|
||||||
|
private const val CIPHER_TRANSFORMATION = "AES/CBC/PKCS5Padding"
|
||||||
|
|
||||||
|
fun encrypt(value: String, key: String): String {
|
||||||
|
val salt = ByteArray(SALT_SIZE).apply {
|
||||||
|
SecureRandom().nextBytes(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
val (keyBytes, iv) = deriveKeyAndIV(key, salt)
|
||||||
|
|
||||||
|
val secretKeySpec = SecretKeySpec(keyBytes, AES_ALGORITHM)
|
||||||
|
val ivParameterSpec = IvParameterSpec(iv)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec)
|
||||||
|
|
||||||
|
val encrypted = cipher.doFinal(value.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
|
||||||
|
val resultBytes = ByteArray(SALTED_PREFIX.length + SALT_SIZE + encrypted.size)
|
||||||
|
|
||||||
|
SALTED_PREFIX.toByteArray().copyInto(resultBytes)
|
||||||
|
salt.copyInto(resultBytes, SALTED_PREFIX.length)
|
||||||
|
encrypted.copyInto(resultBytes, SALTED_PREFIX.length + SALT_SIZE)
|
||||||
|
|
||||||
|
return Base64.encodeToString(resultBytes, Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decrypt(encrypted: String, key: String): String {
|
||||||
|
val encryptedBytes = Base64.decode(encrypted, Base64.NO_WRAP)
|
||||||
|
|
||||||
|
val salt = ByteArray(SALT_SIZE).also {
|
||||||
|
encryptedBytes.copyInto(
|
||||||
|
it,
|
||||||
|
0,
|
||||||
|
SALTED_PREFIX.length,
|
||||||
|
SALTED_PREFIX.length + SALT_SIZE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val (keyBytes, iv) = deriveKeyAndIV(key, salt)
|
||||||
|
|
||||||
|
val secretKeySpec = SecretKeySpec(keyBytes, AES_ALGORITHM)
|
||||||
|
val ivParameterSpec = IvParameterSpec(iv)
|
||||||
|
|
||||||
|
val cipher = Cipher.getInstance(CIPHER_TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec)
|
||||||
|
|
||||||
|
val ciphertext = ByteArray(encryptedBytes.size - (SALTED_PREFIX.length + SALT_SIZE))
|
||||||
|
encryptedBytes.copyInto(ciphertext, 0, SALTED_PREFIX.length + SALT_SIZE)
|
||||||
|
|
||||||
|
val decrypted = cipher.doFinal(ciphertext)
|
||||||
|
|
||||||
|
return String(decrypted, StandardCharsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deriveKeyAndIV(key: String, salt: ByteArray): Pair<ByteArray, ByteArray> {
|
||||||
|
val keyBytes = key.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
val result = ByteArray(KEY_SIZE + IV_SIZE)
|
||||||
|
val md5 = MessageDigest.getInstance(MD5_ALGORITHM)
|
||||||
|
|
||||||
|
var currentResult = md5.digest(keyBytes + salt)
|
||||||
|
currentResult.copyInto(result, 0, 0, currentResult.size)
|
||||||
|
|
||||||
|
var keyMaterial = currentResult.size
|
||||||
|
while (keyMaterial < result.size) {
|
||||||
|
currentResult = md5.digest(currentResult + keyBytes + salt)
|
||||||
|
val copyLength = minOf(currentResult.size, result.size - keyMaterial)
|
||||||
|
currentResult.copyInto(result, keyMaterial, 0, copyLength)
|
||||||
|
keyMaterial += copyLength
|
||||||
|
}
|
||||||
|
|
||||||
|
return Pair(
|
||||||
|
result.copyOfRange(0, KEY_SIZE),
|
||||||
|
result.copyOfRange(KEY_SIZE, KEY_SIZE + IV_SIZE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,143 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.sakuramanhwa
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.source.model.Filter
|
||||||
|
import okhttp3.HttpUrl.Builder
|
||||||
|
|
||||||
|
internal const val GroupTypeNone = -1
|
||||||
|
internal const val GroupTypeSearch = 0
|
||||||
|
internal const val GroupTypeLatesUpdates = 1
|
||||||
|
|
||||||
|
internal class GroupFilter(
|
||||||
|
i18n: I18nDictionary,
|
||||||
|
) : Filter.Select<String>(
|
||||||
|
"",
|
||||||
|
arrayOf(
|
||||||
|
i18n.library.title,
|
||||||
|
i18n.home.lastUpdatesNormal,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
fun setUrlPath(builder: Builder): Int {
|
||||||
|
val path = when (state) {
|
||||||
|
GroupTypeSearch -> "/v1/manga"
|
||||||
|
GroupTypeLatesUpdates -> "/v1/manga/search/latesUpdates"
|
||||||
|
else -> {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
builder.encodedPath(path)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal open class UrlFilter(
|
||||||
|
title: String,
|
||||||
|
private val requireState: Int,
|
||||||
|
val lis: List<List<String>>,
|
||||||
|
) : Filter.Select<String>(
|
||||||
|
title,
|
||||||
|
lis.map { it[0] }.toTypedArray(),
|
||||||
|
) {
|
||||||
|
fun checkGroupState(groupState: Int): Boolean {
|
||||||
|
if (state != 0 && (requireState == GroupTypeNone || requireState == groupState)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
state = 0
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class CategoryFilter(
|
||||||
|
i18n: I18nDictionary,
|
||||||
|
lis: List<List<String>> = listOf(
|
||||||
|
listOf(i18n.home.updates.buttons.genres["all"]!!, "author", ""),
|
||||||
|
listOf(i18n.home.updates.buttons.genres["mature"]!!, "author", "mature"),
|
||||||
|
listOf(i18n.home.updates.buttons.genres["normal"]!!, "author", "normal"),
|
||||||
|
),
|
||||||
|
) : UrlFilter(
|
||||||
|
i18n.library.filter["category"]!!,
|
||||||
|
GroupTypeNone,
|
||||||
|
lis,
|
||||||
|
) {
|
||||||
|
fun setUrlParam(builder: Builder, groupState: Int) {
|
||||||
|
if (!checkGroupState(groupState)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (groupState == GroupTypeSearch) {
|
||||||
|
builder.setQueryParameter("${lis[state][1]}[]", lis[state][2])
|
||||||
|
} else {
|
||||||
|
builder.setQueryParameter(lis[state][1], lis[state][2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class SortFilter(
|
||||||
|
i18n: I18nDictionary,
|
||||||
|
lis: List<List<String>> = listOf(
|
||||||
|
listOf("_", "sort", ""),
|
||||||
|
listOf("${i18n.library.sort["title"]!!}⬇️", "sort", "title"),
|
||||||
|
listOf("${i18n.library.sort["title"]!!}⬆️", "sort", "title"),
|
||||||
|
listOf("${i18n.library.sort["type"]!!}⬇️", "sort", "type"),
|
||||||
|
listOf("${i18n.library.sort["type"]!!}⬆️", "sort", "type"),
|
||||||
|
listOf("${i18n.library.sort["rating"]!!}⬇️", "sort", "rating"),
|
||||||
|
listOf("${i18n.library.sort["rating"]!!}⬆️", "sort", "rating"),
|
||||||
|
listOf("${i18n.library.sort["date"]!!}⬇️", "sort", "create_at"),
|
||||||
|
listOf("${i18n.library.sort["date"]!!}⬆️", "sort", "create_at"),
|
||||||
|
),
|
||||||
|
) : UrlFilter(
|
||||||
|
i18n.library.filter["sortBy"]!!,
|
||||||
|
GroupTypeSearch,
|
||||||
|
lis,
|
||||||
|
) {
|
||||||
|
fun setUrlParam(builder: Builder, groupState: Int) {
|
||||||
|
if (!checkGroupState(groupState)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
builder.setQueryParameter(lis[state][1], lis[state][2])
|
||||||
|
builder.setQueryParameter("order", if (state % 2 == 1) "desc" else "asc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class LanguageCheckBoxFilter(name: String, val key: String) : Filter.CheckBox(name) {
|
||||||
|
override fun toString(): String {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class LanguageCheckBoxFilterGroup(
|
||||||
|
i18n: I18nDictionary,
|
||||||
|
data: LinkedHashMap<String, String> = linkedMapOf(
|
||||||
|
i18n.home.updates.buttons.language["all"]!! to "",
|
||||||
|
i18n.home.updates.buttons.language["spanish"]!! to "esp",
|
||||||
|
i18n.home.updates.buttons.language["english"]!! to "eng",
|
||||||
|
i18n.home.updates.buttons.language["chinese"]!! to "ch",
|
||||||
|
i18n.home.updates.buttons.language["raw"]!! to "raw",
|
||||||
|
),
|
||||||
|
) : Filter.Group<LanguageCheckBoxFilter>(
|
||||||
|
i18n.library.filter["language"]!!,
|
||||||
|
data.map { (k, v) ->
|
||||||
|
LanguageCheckBoxFilter(k, v)
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
fun setUrlParam(builder: Builder, groupState: Int) {
|
||||||
|
if (state[0].state) {
|
||||||
|
// clear
|
||||||
|
state.forEach { it.state = false }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var langParam = false
|
||||||
|
state.forEach {
|
||||||
|
if (it.state) {
|
||||||
|
if (groupState == GroupTypeSearch) {
|
||||||
|
builder.addQueryParameter("language[]", it.toString())
|
||||||
|
} else {
|
||||||
|
if (langParam) {
|
||||||
|
it.state = false
|
||||||
|
} else {
|
||||||
|
builder.addQueryParameter("language", it.toString())
|
||||||
|
langParam = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.sakuramanhwa
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import keiyoushi.utils.toJsonString
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okio.IOException
|
||||||
|
|
||||||
|
internal class I18nHelper(
|
||||||
|
val baseUrl: String,
|
||||||
|
val client: OkHttpClient,
|
||||||
|
val preference: SharedPreferences,
|
||||||
|
) {
|
||||||
|
private val i18nCache: HashMap<String, I18nDictionary> = run {
|
||||||
|
val i18nJson = preference.getString(APP_I18N_KEY, null) ?: return@run hashMapOf()
|
||||||
|
try {
|
||||||
|
i18nJson.parseAs<HashMap<String, I18nDictionary>>()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
hashMapOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getI18nByLanguage(lang: String): I18nDictionary {
|
||||||
|
var i18nDictionary = i18nCache[lang]
|
||||||
|
if (i18nDictionary != null) {
|
||||||
|
return i18nDictionary
|
||||||
|
}
|
||||||
|
|
||||||
|
val request = GET("$baseUrl/assets/i18n/$lang.json?v=2")
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
response.close()
|
||||||
|
throw IOException("Unexpected get i18n(${request.url}) error")
|
||||||
|
}
|
||||||
|
i18nDictionary = response.parseAs<I18nDictionary>()
|
||||||
|
i18nCache[lang] = i18nDictionary
|
||||||
|
|
||||||
|
preference.edit().putString(APP_I18N_KEY, i18nCache.toJsonString()).apply()
|
||||||
|
|
||||||
|
return i18nDictionary
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,374 @@
|
|||||||
|
package eu.kanade.tachiyomi.extension.all.sakuramanhwa
|
||||||
|
|
||||||
|
import androidx.preference.ListPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
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 keiyoushi.utils.getPreferences
|
||||||
|
import keiyoushi.utils.parseAs
|
||||||
|
import keiyoushi.utils.tryParse
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.IOException
|
||||||
|
import rx.Observable
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class SakuraManhwa(
|
||||||
|
override val lang: String = "all",
|
||||||
|
) : HttpSource(), ConfigurableSource {
|
||||||
|
override val name = "SakuraManhwa"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val baseUrl = "https://api.sakuramanhwa.com"
|
||||||
|
|
||||||
|
private val apiImageUrl = "https://api.sakuramanhwa.com/v1/images"
|
||||||
|
private val cdnImageUrl = "https://cdn.sakuramanhwa.com/v1/images"
|
||||||
|
|
||||||
|
private val secretKey = "EA^UfBOF9lNdQDS3i2qAnsqxIrTpH%"
|
||||||
|
private val encryptKey = "6dFGd4Laa3vE%kLpr5eCtSEaAL%wJm"
|
||||||
|
|
||||||
|
private val apiCryptoHelper = CryptoHelper(baseUrl, secretKey, encryptKey)
|
||||||
|
|
||||||
|
override val client: OkHttpClient =
|
||||||
|
network.cloudflareClient.newBuilder().addInterceptor(apiCryptoHelper)
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val request = chain.request()
|
||||||
|
val response = chain.proceed(request)
|
||||||
|
if (!response.isSuccessful && request.url.toString().startsWith(baseUrl)) {
|
||||||
|
throw IOException(response.body.string())
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}.build().also { apiCryptoHelper.setClient(it) }
|
||||||
|
|
||||||
|
private val preference = getPreferences()
|
||||||
|
|
||||||
|
private val i18nHelper: I18nHelper = I18nHelper("https://sakuramanhwa.com", client, preference)
|
||||||
|
|
||||||
|
// Chapter
|
||||||
|
|
||||||
|
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ENGLISH)
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val data = response.parseAs<ApiMangaInfo>()
|
||||||
|
val tag = when (data.manga.details.type) {
|
||||||
|
"manhwa" -> "api"
|
||||||
|
"manga" -> "cdn"
|
||||||
|
else -> throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
val lis = mutableListOf<SChapter>()
|
||||||
|
data.chapters.forEach {
|
||||||
|
val chapterName = getChapterName(it.number)
|
||||||
|
lis.add(
|
||||||
|
SChapter.create().apply {
|
||||||
|
url = "$tag/v1/manga/${data.manga.details.slug}/chapter/$chapterName"
|
||||||
|
name = "${chapterName}${if (it.title != null) " ${it.title}" else ""}"
|
||||||
|
date_upload = dateFormat.tryParse(it.create_at)
|
||||||
|
chapter_number = it.number
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lis
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getChapterName(number: Float): String {
|
||||||
|
return if (number % 1 == 0f) "${number.toInt()}" else "$number"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image
|
||||||
|
|
||||||
|
override fun imageRequest(page: Page): Request {
|
||||||
|
val (tag, realUrl) = getTagUrl(page.imageUrl!!)
|
||||||
|
val prefixUrl = when (tag) {
|
||||||
|
"api" -> apiImageUrl
|
||||||
|
"cdn" -> cdnImageUrl
|
||||||
|
else -> throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
return GET("$prefixUrl$realUrl", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
// LatestUpdates
|
||||||
|
|
||||||
|
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
|
||||||
|
return focusFetchManga(page == 1, this::latestUpdatesRequest, this::latestUpdatesParse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response)
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request = GET(
|
||||||
|
baseUrl.toHttpUrl().newBuilder().encodedPath("/v1/manga/search/latesUpdates")
|
||||||
|
.addQueryParameter("limit", "72").addQueryParameter("page", "$page").build().toString(),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Details
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val data = response.parseAs<ApiMangaInfo>()
|
||||||
|
|
||||||
|
return mangaDetailsToSManga(data.manga.details).apply {
|
||||||
|
genre = listOf(
|
||||||
|
genre!!,
|
||||||
|
"follows: ${data.manga.follows}",
|
||||||
|
"views: ${data.manga.views}",
|
||||||
|
).joinToString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mangaDetailsToSManga(details: MangaDetails): SManga {
|
||||||
|
return SManga.create().apply {
|
||||||
|
url = "/v1/manga/findBySlug/${details.slug}"
|
||||||
|
title = getTitle(details.title, details.language)
|
||||||
|
genre = listOf(
|
||||||
|
"lang: ${details.language}",
|
||||||
|
"type: ${details.type}",
|
||||||
|
"author: ${details.author}",
|
||||||
|
"rating: ${details.rating}",
|
||||||
|
).joinToString()
|
||||||
|
status = if (details.status == "ongoing") SManga.ONGOING else SManga.COMPLETED
|
||||||
|
thumbnail_url = "$baseUrl/v1/images/manga${details.img}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
|
||||||
|
private fun getTagUrl(tagUrl: String): Pair<String, String> {
|
||||||
|
return Pair(tagUrl.substring(0, 3), tagUrl.substring(3))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||||
|
val (tag, realUrl) = getTagUrl(chapter.url)
|
||||||
|
|
||||||
|
val response = client.newCall(GET("$baseUrl$realUrl", headers)).execute()
|
||||||
|
val data = response.parseAs<ApiChapterInfo>()
|
||||||
|
|
||||||
|
val lis = mutableListOf<Page>()
|
||||||
|
data.chapter.images.forEachIndexed { index, it ->
|
||||||
|
lis.add(Page(index, imageUrl = "$tag/chapter$it"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Observable.just(lis)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> =
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
// Popular
|
||||||
|
|
||||||
|
// Independent page-turn count, used to force content filtering, single request if there is no content and there is the next page to continue the request
|
||||||
|
private var focusPage: Int = 1
|
||||||
|
|
||||||
|
private fun focusFetchManga(
|
||||||
|
reset: Boolean,
|
||||||
|
reqFunc: (page: Int) -> Request,
|
||||||
|
respFunc: (response: Response) -> MangasPage,
|
||||||
|
): Observable<MangasPage> {
|
||||||
|
if (reset) {
|
||||||
|
focusPage = 1
|
||||||
|
}
|
||||||
|
var hasNextPage = true
|
||||||
|
var mangasPage: MangasPage? = null
|
||||||
|
while (hasNextPage) {
|
||||||
|
val request = reqFunc(focusPage)
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
mangasPage = respFunc(response)
|
||||||
|
hasNextPage = mangasPage.hasNextPage
|
||||||
|
focusPage++
|
||||||
|
if (mangasPage.mangas.isNotEmpty()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Observable.just(mangasPage!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||||
|
return focusFetchManga(page == 1, this::popularMangaRequest, this::popularMangaParse)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage {
|
||||||
|
val data = response.parseAs<ApiMangaList>()
|
||||||
|
|
||||||
|
val focus = preference.getString(APP_FOCUS_LANGUAGE_KEY, "")!!
|
||||||
|
|
||||||
|
val lis = mutableListOf<SManga>()
|
||||||
|
data.mangas.forEach {
|
||||||
|
if (focus == "" || focus == it.language) {
|
||||||
|
lis.add(mangaDetailsToSManga(it))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return MangasPage(lis, data.next_page != null)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request {
|
||||||
|
return GET(
|
||||||
|
baseUrl.toHttpUrl().newBuilder().encodedPath("/v1/manga/views/top")
|
||||||
|
.addQueryParameter("limit", "72").addQueryParameter("page", "$page").build()
|
||||||
|
.toString(),
|
||||||
|
headers,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search
|
||||||
|
|
||||||
|
override fun fetchSearchManga(
|
||||||
|
page: Int,
|
||||||
|
query: String,
|
||||||
|
filters: FilterList,
|
||||||
|
): Observable<MangasPage> {
|
||||||
|
return focusFetchManga(
|
||||||
|
page == 1,
|
||||||
|
{ currentPage -> this.searchMangaRequest(currentPage, query, filters) },
|
||||||
|
this::searchMangaParse,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response)
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
val url = baseUrl.toHttpUrl().newBuilder().apply {
|
||||||
|
var groupState = 0
|
||||||
|
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is GroupFilter -> {
|
||||||
|
groupState = filter.setUrlPath(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
is CategoryFilter -> {
|
||||||
|
filter.setUrlParam(this, groupState)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SortFilter -> {
|
||||||
|
filter.setUrlParam(this, groupState)
|
||||||
|
}
|
||||||
|
|
||||||
|
is LanguageCheckBoxFilterGroup -> {
|
||||||
|
filter.setUrlParam(this, groupState)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupState == GroupTypeSearch && query.isNotBlank()) {
|
||||||
|
addQueryParameter("search", query)
|
||||||
|
}
|
||||||
|
addQueryParameter("limit", "72")
|
||||||
|
addQueryParameter("page", page.toString())
|
||||||
|
}.build().toString()
|
||||||
|
|
||||||
|
return GET(url, headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTitle(title: String, lang: String): String {
|
||||||
|
return capitalizeWords(title.removeSuffix(lang))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun capitalizeWords(str: String): String {
|
||||||
|
return str.split(" ").joinToString(" ") {
|
||||||
|
it.replaceFirstChar { char ->
|
||||||
|
if (char.isLowerCase()) char.titlecase() else char.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
|
||||||
|
override fun getFilterList(): FilterList {
|
||||||
|
val i18nDictionary = getI18nDictionary()
|
||||||
|
return FilterList(
|
||||||
|
GroupFilter(i18nDictionary),
|
||||||
|
CategoryFilter(i18nDictionary),
|
||||||
|
SortFilter(i18nDictionary),
|
||||||
|
LanguageCheckBoxFilterGroup(i18nDictionary),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
ListPreference(screen.context).apply {
|
||||||
|
title = getI18nDictionary().library.filter["language"]
|
||||||
|
key = APP_LANGUAGE_KEY
|
||||||
|
entries = arrayOf(
|
||||||
|
"🇬🇧English",
|
||||||
|
"🇪🇸Español",
|
||||||
|
"🇨🇳中文",
|
||||||
|
"🇷🇺Русский",
|
||||||
|
"🇹🇷Türkçe",
|
||||||
|
"🇮🇩Bahasa Indonesia",
|
||||||
|
"🇹🇭ไทย",
|
||||||
|
"🇻🇳Tiếng Việt",
|
||||||
|
)
|
||||||
|
entryValues = arrayOf(
|
||||||
|
"en",
|
||||||
|
"es",
|
||||||
|
"zh",
|
||||||
|
"ru",
|
||||||
|
"tr",
|
||||||
|
"id",
|
||||||
|
"th",
|
||||||
|
"vi",
|
||||||
|
)
|
||||||
|
setDefaultValue(entryValues[0])
|
||||||
|
setOnPreferenceChangeListener { _, click ->
|
||||||
|
try {
|
||||||
|
getI18nDictionary(click as String)
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.let { screen.addPreference(it) }
|
||||||
|
|
||||||
|
// Lock the filter language type from the result.
|
||||||
|
// Non-locked content is simply ignored, which makes the experience more comfortable.
|
||||||
|
ListPreference(screen.context).apply {
|
||||||
|
val i18nDictionary = getI18nDictionary()
|
||||||
|
title = "👀➡️🔒"
|
||||||
|
key = APP_FOCUS_LANGUAGE_KEY
|
||||||
|
entries = arrayOf(
|
||||||
|
"🔓",
|
||||||
|
"🇬🇧${i18nDictionary.home.updates.buttons.language["english"]!!}🔒",
|
||||||
|
"🇪🇸${i18nDictionary.home.updates.buttons.language["spanish"]!!}🔒",
|
||||||
|
"🇨🇳${i18nDictionary.home.updates.buttons.language["chinese"]!!}🔒",
|
||||||
|
"${i18nDictionary.home.updates.buttons.language["raw"]!!}🔒",
|
||||||
|
)
|
||||||
|
entryValues = arrayOf(
|
||||||
|
"",
|
||||||
|
"eng",
|
||||||
|
"esp",
|
||||||
|
"ch",
|
||||||
|
"raw",
|
||||||
|
)
|
||||||
|
setDefaultValue(entryValues[0])
|
||||||
|
}.let { screen.addPreference(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getI18nDictionary(language: String? = null): I18nDictionary {
|
||||||
|
val currentLang = language ?: preference.getString(APP_LANGUAGE_KEY, "en")!!
|
||||||
|
return runBlocking {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
i18nHelper.getI18nByLanguage(currentLang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal const val APP_LANGUAGE_KEY = "APP_LANGUAGE_KEY"
|
||||||
|
internal const val APP_I18N_KEY = "APP_I18N_KEY"
|
||||||
|
internal const val APP_FOCUS_LANGUAGE_KEY = "APP_FOCUS_LANGUAGE_KEY"
|
Loading…
x
Reference in New Issue
Block a user