Add Mangamo (#2862)
* Add Mangamo * Mangamo: Remove excess fields and use assertions * Mangamo: improve Firestore queries * Mangamo: synchronize auth * Mangamo: fix serialization bug when no fields are returned from query * Mangamo: exclude disabled manga from latest and improve chapter update query * Mangamo: clean up DTO objects * Mangamo: add custom 401 messaging, use emoji for payment indication * Mangamo: make manga/chapter URLs relative
This commit is contained in:
parent
20b50323b9
commit
21f03c9454
|
@ -0,0 +1,8 @@
|
||||||
|
ext {
|
||||||
|
extName = 'Mangamo'
|
||||||
|
extClass = '.Mangamo'
|
||||||
|
extVersionCode = 1
|
||||||
|
isNsfw = false
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 5.1 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
|
@ -0,0 +1,187 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
|
||||||
|
class FirestoreRequestFactory(
|
||||||
|
private val helper: MangamoHelper,
|
||||||
|
private val auth: MangamoAuth,
|
||||||
|
) {
|
||||||
|
|
||||||
|
open class DocumentQuery {
|
||||||
|
var fields = listOf<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
class CollectionQuery : DocumentQuery() {
|
||||||
|
|
||||||
|
var filter: Filter? = null
|
||||||
|
var orderBy: List<OrderByTerm>? = null
|
||||||
|
|
||||||
|
// Firestore supports cursors, but this is simpler and probably good enough
|
||||||
|
var limit: Int? = null
|
||||||
|
var offset: Int? = null
|
||||||
|
|
||||||
|
class OrderByTerm(private val field: String, private val direction: Direction) {
|
||||||
|
enum class Direction { ASCENDING, DESCENDING }
|
||||||
|
|
||||||
|
fun toJsonString() = """{"direction":"$direction","field":{"fieldPath":"$field"}}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ascending(field: String) =
|
||||||
|
OrderByTerm(field, OrderByTerm.Direction.ASCENDING)
|
||||||
|
fun descending(field: String) =
|
||||||
|
OrderByTerm(field, OrderByTerm.Direction.DESCENDING)
|
||||||
|
|
||||||
|
sealed interface Filter {
|
||||||
|
fun toJsonString(): String
|
||||||
|
|
||||||
|
class CompositeFilter(private val op: Operator, private val filters: List<Filter>) : Filter {
|
||||||
|
enum class Operator { AND, OR }
|
||||||
|
|
||||||
|
override fun toJsonString(): String =
|
||||||
|
"""{"compositeFilter":{"op":"$op","filters":[${filters.joinToString { it.toJsonString() }}]}}"""
|
||||||
|
}
|
||||||
|
|
||||||
|
class FieldFilter(private val fieldName: String, private val op: Operator, private val value: Any?) : Filter {
|
||||||
|
enum class Operator {
|
||||||
|
LESS_THAN,
|
||||||
|
LESS_THAN_OR_EQUAL,
|
||||||
|
GREATER_THAN,
|
||||||
|
GREATER_THAN_OR_EQUAL,
|
||||||
|
EQUAL,
|
||||||
|
NOT_EQUAL,
|
||||||
|
ARRAY_CONTAINS,
|
||||||
|
IN,
|
||||||
|
ARRAY_CONTAINS_ANY,
|
||||||
|
NOT_IN,
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toJsonString(): String {
|
||||||
|
val valueTerm =
|
||||||
|
when (value) {
|
||||||
|
null -> "{\"nullValue\":null}"
|
||||||
|
is Int -> "{\"integerValue\":$value}"
|
||||||
|
is Double -> "{\"doubleValue\":$value}"
|
||||||
|
is String -> "{\"stringValue\":\"$value\"}"
|
||||||
|
is Boolean -> "{\"booleanValue\":$value}"
|
||||||
|
else -> throw Exception("${value.javaClass} not supported in field filters")
|
||||||
|
}
|
||||||
|
return """{"fieldFilter":{"op":"$op","field":{"fieldPath":"$fieldName"},"value":$valueTerm}}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnaryFilter(private val fieldName: String, private val op: Operator) : Filter {
|
||||||
|
enum class Operator { IS_NAN, IS_NULL, IS_NOT_NAN, IS_NOT_NULL }
|
||||||
|
|
||||||
|
override fun toJsonString(): String {
|
||||||
|
return """{"unaryFilter":{"op":"$op","field":{"fieldPath":"$fieldName"}}}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun and(vararg filters: Filter) =
|
||||||
|
Filter.CompositeFilter(Filter.CompositeFilter.Operator.AND, filters.toList())
|
||||||
|
fun or(vararg filters: Filter) =
|
||||||
|
Filter.CompositeFilter(Filter.CompositeFilter.Operator.OR, filters.toList())
|
||||||
|
fun isLessThan(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.LESS_THAN, value)
|
||||||
|
fun isLessThanOrEqual(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.LESS_THAN_OR_EQUAL, value)
|
||||||
|
fun isGreaterThan(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.GREATER_THAN, value)
|
||||||
|
fun isGreaterThanOrEqual(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.GREATER_THAN_OR_EQUAL, value)
|
||||||
|
fun isEqual(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.EQUAL, value)
|
||||||
|
fun isNotEqual(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.NOT_EQUAL, value)
|
||||||
|
fun contains(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.ARRAY_CONTAINS, value)
|
||||||
|
fun isIn(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.IN, value)
|
||||||
|
fun containsAny(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.ARRAY_CONTAINS_ANY, value)
|
||||||
|
fun isNotIn(fieldName: String, value: Any?) =
|
||||||
|
Filter.FieldFilter(fieldName, Filter.FieldFilter.Operator.NOT_IN, value)
|
||||||
|
fun isNaN(fieldName: String) =
|
||||||
|
Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NAN)
|
||||||
|
fun isNull(fieldName: String) =
|
||||||
|
Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NULL)
|
||||||
|
fun isNotNaN(fieldName: String) =
|
||||||
|
Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NOT_NAN)
|
||||||
|
fun isNotNull(fieldName: String) =
|
||||||
|
Filter.UnaryFilter(fieldName, Filter.UnaryFilter.Operator.IS_NOT_NULL)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDocument(path: String, query: DocumentQuery.() -> Unit = {}): Request {
|
||||||
|
val queryInfo = DocumentQuery()
|
||||||
|
query(queryInfo)
|
||||||
|
|
||||||
|
val urlBuilder = "${MangamoConstants.FIRESTORE_API_BASE_PATH}/$path".toHttpUrl().newBuilder()
|
||||||
|
|
||||||
|
for (field in queryInfo.fields) {
|
||||||
|
urlBuilder.addQueryParameter("mask.fieldPaths", field)
|
||||||
|
}
|
||||||
|
|
||||||
|
val headers = Headers.Builder()
|
||||||
|
.add("Authorization", "Bearer ${auth.getIdToken()}")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return GET(urlBuilder.build(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deconstructCollectionPath(path: String): Pair<String, String> {
|
||||||
|
val pivot = path.lastIndexOf('/')
|
||||||
|
if (pivot == -1) {
|
||||||
|
return Pair("", path)
|
||||||
|
}
|
||||||
|
return Pair(path.substring(0, pivot), path.substring(pivot + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCollection(
|
||||||
|
fullPath: String,
|
||||||
|
query: CollectionQuery.() -> Unit = {},
|
||||||
|
): Request {
|
||||||
|
val queryInfo = CollectionQuery()
|
||||||
|
query(queryInfo)
|
||||||
|
|
||||||
|
val structuredQuery = mutableMapOf<String, String?>()
|
||||||
|
|
||||||
|
val (path, collectionId) = deconstructCollectionPath(fullPath)
|
||||||
|
|
||||||
|
structuredQuery["from"] = "{\"collectionId\":\"$collectionId\"}"
|
||||||
|
|
||||||
|
if (queryInfo.fields.isNotEmpty()) {
|
||||||
|
structuredQuery["select"] = "{\"fields\":[${queryInfo.fields.joinToString {
|
||||||
|
"{\"fieldPath\":\"$it\"}"
|
||||||
|
}}]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryInfo.filter != null) {
|
||||||
|
structuredQuery["where"] = queryInfo.filter!!.toJsonString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryInfo.orderBy != null) {
|
||||||
|
structuredQuery["orderBy"] = "[${queryInfo.orderBy!!.joinToString { it.toJsonString() }}]"
|
||||||
|
}
|
||||||
|
|
||||||
|
structuredQuery["offset"] = queryInfo.offset?.toString()
|
||||||
|
structuredQuery["limit"] = queryInfo.limit?.toString()
|
||||||
|
|
||||||
|
val headers = helper.jsonHeaders.newBuilder()
|
||||||
|
.add("Authorization", "Bearer ${auth.getIdToken()}")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val body = "{\"structuredQuery\":{${
|
||||||
|
structuredQuery.entries
|
||||||
|
.filter { it.value != null }
|
||||||
|
.joinToString { "\"${it.key}\":${it.value}" }
|
||||||
|
}}}".toRequestBody()
|
||||||
|
|
||||||
|
return POST("${MangamoConstants.FIRESTORE_API_BASE_PATH}/$path:runQuery", headers, body)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,463 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.preference.EditTextPreference
|
||||||
|
import androidx.preference.MultiSelectListPreference
|
||||||
|
import androidx.preference.PreferenceScreen
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.MangamoHelper.Companion.parseJson
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.ChapterDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.PageDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.QueryResultDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.SeriesDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.UserDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.documents
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.elements
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
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 okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import rx.Observable
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class Mangamo : ConfigurableSource, HttpSource() {
|
||||||
|
|
||||||
|
override val name = "Mangamo"
|
||||||
|
|
||||||
|
override val lang = "en"
|
||||||
|
|
||||||
|
override val baseUrl = "https://mangamo.com"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
private val preferences: SharedPreferences by lazy {
|
||||||
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val helper = MangamoHelper(headers)
|
||||||
|
|
||||||
|
private var userToken = ""
|
||||||
|
get() {
|
||||||
|
if (field == "") {
|
||||||
|
field = preferences.getString(MangamoConstants.USER_TOKEN_PREF, "")!!
|
||||||
|
|
||||||
|
if (field == "") {
|
||||||
|
field = MangamoAuth.createAnonymousUserToken(client)
|
||||||
|
preferences.edit()
|
||||||
|
.putString(MangamoConstants.USER_TOKEN_PREF, field)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
private val auth by cachedBy({ userToken }) {
|
||||||
|
MangamoAuth(helper, client, userToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val firestore by cachedBy({ auth }) {
|
||||||
|
FirestoreRequestFactory(helper, auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val user by cachedBy({ Pair(userToken, firestore) }) {
|
||||||
|
val response = client.newCall(
|
||||||
|
firestore.getDocument("Users/$userToken") {
|
||||||
|
fields = listOf(UserDto::isSubscribed.name)
|
||||||
|
},
|
||||||
|
).execute()
|
||||||
|
response.body.string().parseJson<DocumentDto<UserDto>>().fields
|
||||||
|
}
|
||||||
|
|
||||||
|
private val coinMangaPref
|
||||||
|
get() = preferences.getStringSet(MangamoConstants.HIDE_COIN_MANGA_PREF, setOf())!!
|
||||||
|
private val exclusivesOnlyPref
|
||||||
|
get() = preferences.getStringSet(MangamoConstants.EXCLUSIVES_ONLY_PREF, setOf())!!
|
||||||
|
|
||||||
|
override val client: OkHttpClient = super.client.newBuilder()
|
||||||
|
.addNetworkInterceptor {
|
||||||
|
val request = it.request()
|
||||||
|
val response = it.proceed(request)
|
||||||
|
|
||||||
|
if (request.url.toString().startsWith("${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/page")) {
|
||||||
|
if (response.code == 401) {
|
||||||
|
throw IOException("You don't have access to this chapter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
.addNetworkInterceptor {
|
||||||
|
val response = it.proceed(it.request())
|
||||||
|
|
||||||
|
// Add Cache-Control to Firestore queries
|
||||||
|
if (it.request().url.toString().startsWith(MangamoConstants.FIRESTORE_API_BASE_PATH)) {
|
||||||
|
return@addNetworkInterceptor response.newBuilder()
|
||||||
|
.header("Cache-Control", "public, max-age=${MangamoConstants.FIRESTORE_CACHE_LENGTH}")
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val seriesRequiredFields = listOf(
|
||||||
|
SeriesDto::id.name,
|
||||||
|
SeriesDto::name.name,
|
||||||
|
SeriesDto::name_lowercase.name,
|
||||||
|
SeriesDto::description.name,
|
||||||
|
SeriesDto::authors.name,
|
||||||
|
SeriesDto::genres.name,
|
||||||
|
SeriesDto::ongoing.name,
|
||||||
|
SeriesDto::releaseStatusTag.name,
|
||||||
|
SeriesDto::titleArt.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun processSeries(dto: SeriesDto) = SManga.create().apply {
|
||||||
|
author = dto.authors?.joinToString { it.name }
|
||||||
|
description = dto.description
|
||||||
|
genre = dto.genres?.joinToString { it.name }
|
||||||
|
status = helper.getSeriesStatus(dto)
|
||||||
|
thumbnail_url = dto.titleArt
|
||||||
|
title = dto.name!!
|
||||||
|
url = helper.getSeriesUrl(dto)
|
||||||
|
initialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseMangaPage(response: Response, filterPredicate: (SeriesDto) -> Boolean = { true }): MangasPage {
|
||||||
|
val collection = response.body.string().parseJson<QueryResultDto<SeriesDto>>()
|
||||||
|
|
||||||
|
val isDone = collection.documents.size < MangamoConstants.BROWSE_PAGE_SIZE
|
||||||
|
|
||||||
|
val results = collection.elements.filter(filterPredicate)
|
||||||
|
|
||||||
|
return MangasPage(results.map { processSeries(it) }, !isDone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popular manga
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int): Request = firestore.getCollection("Series") {
|
||||||
|
limit = MangamoConstants.BROWSE_PAGE_SIZE
|
||||||
|
offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE
|
||||||
|
|
||||||
|
val fields = seriesRequiredFields.toMutableList()
|
||||||
|
this.fields = fields
|
||||||
|
|
||||||
|
if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) {
|
||||||
|
fields += SeriesDto::onlyTransactional.name
|
||||||
|
}
|
||||||
|
|
||||||
|
val prefFilters =
|
||||||
|
if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) {
|
||||||
|
isEqual(SeriesDto::onlyOnMangamo.name, true)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
filter = and(
|
||||||
|
*listOfNotNull(
|
||||||
|
isEqual(SeriesDto::enabled.name, true),
|
||||||
|
prefFilters,
|
||||||
|
).toTypedArray(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage = parseMangaPage(response) {
|
||||||
|
if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) {
|
||||||
|
if (it.onlyTransactional == true) {
|
||||||
|
return@parseMangaPage false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest manga
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request = firestore.getCollection("Series") {
|
||||||
|
limit = MangamoConstants.BROWSE_PAGE_SIZE
|
||||||
|
offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE
|
||||||
|
|
||||||
|
val fields = seriesRequiredFields.toMutableList()
|
||||||
|
this.fields = fields
|
||||||
|
|
||||||
|
fields += SeriesDto::enabled.name
|
||||||
|
|
||||||
|
if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) {
|
||||||
|
fields += SeriesDto::onlyTransactional.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) {
|
||||||
|
fields += SeriesDto::onlyOnMangamo.name
|
||||||
|
}
|
||||||
|
|
||||||
|
orderBy = listOf(descending(SeriesDto::updatedAt.name))
|
||||||
|
|
||||||
|
// Filters can't be used with orderBy because firebase wants there to be indexes
|
||||||
|
// on various fields to support those queries and we can't create them.
|
||||||
|
// Therefore, all filtering has to be done on the client in the parse method.
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage = parseMangaPage(response) {
|
||||||
|
if (it.enabled != true) {
|
||||||
|
return@parseMangaPage false
|
||||||
|
}
|
||||||
|
if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) {
|
||||||
|
if (it.onlyTransactional == true) {
|
||||||
|
return@parseMangaPage false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE)) {
|
||||||
|
if (it.onlyOnMangamo != true) {
|
||||||
|
return@parseMangaPage false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search manga
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = firestore.getCollection("Series") {
|
||||||
|
limit = MangamoConstants.BROWSE_PAGE_SIZE
|
||||||
|
offset = (page - 1) * MangamoConstants.BROWSE_PAGE_SIZE
|
||||||
|
|
||||||
|
val fields = seriesRequiredFields.toMutableList()
|
||||||
|
this.fields = fields
|
||||||
|
|
||||||
|
if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE)) {
|
||||||
|
fields += SeriesDto::onlyTransactional.name
|
||||||
|
}
|
||||||
|
|
||||||
|
if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH)) {
|
||||||
|
fields += SeriesDto::onlyOnMangamo.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding additional filters makes Firestore complain about wanting an index
|
||||||
|
// so we filter on the client in parse, just like for Latest.
|
||||||
|
|
||||||
|
filter = and(
|
||||||
|
isEqual(SeriesDto::enabled.name, true),
|
||||||
|
isGreaterThanOrEqual(SeriesDto::name_lowercase.name, query.lowercase()),
|
||||||
|
isLessThanOrEqual(SeriesDto::name_lowercase.name, query.lowercase() + "\uf8ff"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage = parseMangaPage(response) {
|
||||||
|
if (coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_SEARCH)) {
|
||||||
|
if (it.onlyTransactional == true) {
|
||||||
|
return@parseMangaPage false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (exclusivesOnlyPref.contains(MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH)) {
|
||||||
|
if (it.onlyOnMangamo != true) {
|
||||||
|
return@parseMangaPage false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manga details
|
||||||
|
|
||||||
|
override fun getMangaUrl(manga: SManga): String {
|
||||||
|
return baseUrl + manga.url
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
|
val uri = getMangaUrl(manga).toHttpUrl()
|
||||||
|
|
||||||
|
val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt()
|
||||||
|
|
||||||
|
return firestore.getDocument("Series/$seriesId") {
|
||||||
|
fields = seriesRequiredFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val dto = response.body.string().parseJson<DocumentDto<SeriesDto>>().fields
|
||||||
|
return processSeries(dto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chapter list section
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
|
val uri = getMangaUrl(manga).toHttpUrl()
|
||||||
|
|
||||||
|
val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt()
|
||||||
|
|
||||||
|
val seriesObservable = client.newCall(
|
||||||
|
firestore.getDocument("Series/$seriesId") {
|
||||||
|
fields = listOf(
|
||||||
|
SeriesDto::maxFreeChapterNumber.name,
|
||||||
|
SeriesDto::maxMeteredReadingChapterNumber.name,
|
||||||
|
SeriesDto::onlyTransactional.name,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
).asObservableSuccess().map { response ->
|
||||||
|
response.body.string().parseJson<DocumentDto<SeriesDto>>().fields
|
||||||
|
}
|
||||||
|
|
||||||
|
val chaptersObservable = client.newCall(
|
||||||
|
firestore.getCollection("Series/$seriesId/chapters") {
|
||||||
|
fields = listOf(
|
||||||
|
ChapterDto::enabled.name,
|
||||||
|
ChapterDto::id.name,
|
||||||
|
ChapterDto::seriesId.name,
|
||||||
|
ChapterDto::chapterNumber.name,
|
||||||
|
ChapterDto::name.name,
|
||||||
|
ChapterDto::createdAt.name,
|
||||||
|
ChapterDto::onlyTransactional.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
orderBy = listOf(descending(ChapterDto::chapterNumber.name))
|
||||||
|
},
|
||||||
|
).asObservableSuccess().map { response ->
|
||||||
|
response.body.string().parseJson<QueryResultDto<ChapterDto>>().elements
|
||||||
|
}
|
||||||
|
|
||||||
|
val hideCoinChapters = coinMangaPref.contains(MangamoConstants.HIDE_COIN_MANGA_OPTION_CHAPTERS)
|
||||||
|
|
||||||
|
return Observable.combineLatest(seriesObservable, chaptersObservable) { series, chapters ->
|
||||||
|
chapters
|
||||||
|
.mapNotNull { chapter ->
|
||||||
|
if (chapter.enabled != true) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
val isUserSubscribed = user.isSubscribed == true
|
||||||
|
|
||||||
|
val isFreeChapter = chapter.chapterNumber!! <= (series.maxFreeChapterNumber ?: 0)
|
||||||
|
val isMeteredChapter = chapter.chapterNumber <= (series.maxMeteredReadingChapterNumber ?: 0)
|
||||||
|
val isCoinChapter = chapter.onlyTransactional == true ||
|
||||||
|
(series.onlyTransactional == true && !isFreeChapter)
|
||||||
|
|
||||||
|
if (hideCoinChapters && isCoinChapter) {
|
||||||
|
return@mapNotNull null
|
||||||
|
}
|
||||||
|
|
||||||
|
SChapter.create().apply {
|
||||||
|
chapter_number = chapter.chapterNumber
|
||||||
|
date_upload = chapter.createdAt!!
|
||||||
|
name = chapter.name +
|
||||||
|
if (isCoinChapter) {
|
||||||
|
" \uD83E\uDE99" // coin emoji
|
||||||
|
} else if (isFreeChapter || isUserSubscribed) {
|
||||||
|
""
|
||||||
|
} else if (isMeteredChapter) {
|
||||||
|
" \uD83D\uDD52" // three-o-clock emoji
|
||||||
|
} else {
|
||||||
|
// subscriber chapter
|
||||||
|
" \uD83D\uDD12" // lock emoji
|
||||||
|
}
|
||||||
|
url = helper.getChapterUrl(chapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> =
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
private fun getPagesImagesRequest(series: Int, chapter: Int): Request {
|
||||||
|
return POST(
|
||||||
|
"${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/page/$series/$chapter",
|
||||||
|
helper.jsonHeaders,
|
||||||
|
"{\"idToken\":\"${auth.getIdToken()}\"}".toRequestBody(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListRequest(chapter: SChapter): Request {
|
||||||
|
val uri = (baseUrl + chapter.url).toHttpUrl()
|
||||||
|
|
||||||
|
val seriesId = uri.queryParameter(MangamoConstants.SERIES_QUERY_PARAM)!!.toInt()
|
||||||
|
val chapterId = uri.queryParameter(MangamoConstants.CHAPTER_QUERY_PARAM)!!.toInt()
|
||||||
|
|
||||||
|
return getPagesImagesRequest(seriesId, chapterId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val data = response.body.string().parseJson<List<PageDto>>()
|
||||||
|
|
||||||
|
return data.map {
|
||||||
|
Page(it.pageNumber - 1, imageUrl = it.uri)
|
||||||
|
}.sortedBy { it.index }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()
|
||||||
|
|
||||||
|
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
||||||
|
val userTokenPref = EditTextPreference(screen.context).apply {
|
||||||
|
key = MangamoConstants.USER_TOKEN_PREF
|
||||||
|
summary = "If you are a paying user, enter your user token to authenticate."
|
||||||
|
title = "User Token"
|
||||||
|
|
||||||
|
dialogMessage = """
|
||||||
|
Copy your token from the Mangamo app by going to My Manga > Profile icon (top right) > About and tapping on the "User" string at the bottom.
|
||||||
|
|
||||||
|
Then replace the auto-generated token you see below with your personal token.
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
setDefaultValue("")
|
||||||
|
|
||||||
|
setOnPreferenceChangeListener { _, newValue ->
|
||||||
|
userToken = newValue as String
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val hideCoinMangaPref = MultiSelectListPreference(screen.context).apply {
|
||||||
|
key = MangamoConstants.HIDE_COIN_MANGA_PREF
|
||||||
|
title = "Hide Coin Manga"
|
||||||
|
|
||||||
|
summary = """
|
||||||
|
Hide manga that require coins.
|
||||||
|
|
||||||
|
For technical reasons, manga where a subscription only gives access to some chapters are not considered coin manga, even if coins are required to access all chapters.
|
||||||
|
""".trimIndent()
|
||||||
|
|
||||||
|
entries = arrayOf(
|
||||||
|
"Hide in Popular/Latest",
|
||||||
|
"Hide in Search",
|
||||||
|
"Hide Coin Chapters",
|
||||||
|
)
|
||||||
|
|
||||||
|
entryValues = arrayOf(
|
||||||
|
MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_BROWSE,
|
||||||
|
MangamoConstants.HIDE_COIN_MANGA_OPTION_IN_SEARCH,
|
||||||
|
MangamoConstants.HIDE_COIN_MANGA_OPTION_CHAPTERS,
|
||||||
|
)
|
||||||
|
|
||||||
|
setDefaultValue(setOf<String>())
|
||||||
|
}
|
||||||
|
|
||||||
|
val exclusivesOnly = MultiSelectListPreference(screen.context).apply {
|
||||||
|
key = MangamoConstants.EXCLUSIVES_ONLY_PREF
|
||||||
|
title = "Only Show Exclusives"
|
||||||
|
summary = "Only show Mangamo-exclusive manga."
|
||||||
|
|
||||||
|
entries = arrayOf(
|
||||||
|
"In Popular/Latest",
|
||||||
|
"In Search",
|
||||||
|
)
|
||||||
|
|
||||||
|
entryValues = arrayOf(
|
||||||
|
MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_BROWSE,
|
||||||
|
MangamoConstants.EXCLUSIVES_ONLY_OPTION_IN_SEARCH,
|
||||||
|
)
|
||||||
|
|
||||||
|
setDefaultValue(setOf<String>())
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.addPreference(userTokenPref)
|
||||||
|
screen.addPreference(hideCoinMangaPref)
|
||||||
|
screen.addPreference(exclusivesOnly)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.MangamoHelper.Companion.parseJson
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.FirebaseAuthDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.FirebaseRegisterDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.MangamoLoginDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.TokenRefreshDto
|
||||||
|
import eu.kanade.tachiyomi.network.POST
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.internal.EMPTY_HEADERS
|
||||||
|
|
||||||
|
class MangamoAuth(
|
||||||
|
private val helper: MangamoHelper,
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
private val userToken: String,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private lateinit var currentToken: String
|
||||||
|
private lateinit var refreshToken: String
|
||||||
|
private var expirationTime: Long = 0
|
||||||
|
|
||||||
|
fun getIdToken(): String {
|
||||||
|
synchronized(this) {
|
||||||
|
if (!this::currentToken.isInitialized) {
|
||||||
|
obtainInitialIdToken()
|
||||||
|
}
|
||||||
|
refreshIfNecessary()
|
||||||
|
return currentToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceRefresh() {
|
||||||
|
obtainInitialIdToken()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expireIn(seconds: Long) {
|
||||||
|
expirationTime = System.currentTimeMillis() + (seconds - 1) * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun obtainInitialIdToken() {
|
||||||
|
val mangamoLoginResponse = client.newCall(
|
||||||
|
POST(
|
||||||
|
"${MangamoConstants.FIREBASE_FUNCTION_BASE_PATH}/v3/login",
|
||||||
|
helper.jsonHeaders,
|
||||||
|
"{\"purchaserInfo\":{\"originalAppUserId\":\"$userToken\"}}".toRequestBody(),
|
||||||
|
),
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
val customToken = mangamoLoginResponse.body.string().parseJson<MangamoLoginDto>().accessToken
|
||||||
|
|
||||||
|
val googleIdentityResponse = client.newCall(
|
||||||
|
POST(
|
||||||
|
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${MangamoConstants.FIREBASE_API_KEY}",
|
||||||
|
EMPTY_HEADERS,
|
||||||
|
"{\"token\":\"$customToken\",\"returnSecureToken\":true}".toRequestBody(),
|
||||||
|
),
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
val tokenInfo = googleIdentityResponse.body.string().parseJson<FirebaseAuthDto>()
|
||||||
|
|
||||||
|
currentToken = tokenInfo.idToken
|
||||||
|
refreshToken = tokenInfo.refreshToken
|
||||||
|
expireIn(tokenInfo.expiresIn)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshIfNecessary() {
|
||||||
|
if (System.currentTimeMillis() > expirationTime) {
|
||||||
|
val headers = Headers.Builder()
|
||||||
|
.add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val refreshResponse = client.newCall(
|
||||||
|
POST(
|
||||||
|
"https://securetoken.googleapis.com/v1/token?key=${MangamoConstants.FIREBASE_API_KEY}",
|
||||||
|
headers,
|
||||||
|
"grant_type=refresh_token&refresh_token=$refreshToken".toRequestBody(),
|
||||||
|
),
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
if (refreshResponse.code == 200) {
|
||||||
|
val tokenInfo = refreshResponse.body.string().parseJson<TokenRefreshDto>()
|
||||||
|
|
||||||
|
currentToken = tokenInfo.idToken
|
||||||
|
refreshToken = tokenInfo.refreshToken
|
||||||
|
expireIn(tokenInfo.expiresIn)
|
||||||
|
} else {
|
||||||
|
// Refresh token may have expired
|
||||||
|
obtainInitialIdToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun createAnonymousUserToken(client: OkHttpClient): String {
|
||||||
|
val googleIdentityResponse = client.newCall(
|
||||||
|
POST(
|
||||||
|
"https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=${MangamoConstants.FIREBASE_API_KEY}",
|
||||||
|
EMPTY_HEADERS,
|
||||||
|
"{\"returnSecureToken\":true}".toRequestBody(),
|
||||||
|
),
|
||||||
|
).execute()
|
||||||
|
|
||||||
|
val tokenInfo = googleIdentityResponse.body.string().parseJson<FirebaseRegisterDto>()
|
||||||
|
|
||||||
|
return tokenInfo.localId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,24 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo
|
||||||
|
|
||||||
|
object MangamoConstants {
|
||||||
|
const val USER_TOKEN_PREF = "userToken"
|
||||||
|
const val HIDE_COIN_MANGA_PREF = "hideCoinManga"
|
||||||
|
const val EXCLUSIVES_ONLY_PREF = "onlyShowExclusives"
|
||||||
|
|
||||||
|
const val HIDE_COIN_MANGA_OPTION_IN_BROWSE = "inBrowse"
|
||||||
|
const val HIDE_COIN_MANGA_OPTION_IN_SEARCH = "inSearch"
|
||||||
|
const val HIDE_COIN_MANGA_OPTION_CHAPTERS = "chapters"
|
||||||
|
|
||||||
|
const val EXCLUSIVES_ONLY_OPTION_IN_BROWSE = "inBrowse"
|
||||||
|
const val EXCLUSIVES_ONLY_OPTION_IN_SEARCH = "inSearch"
|
||||||
|
|
||||||
|
const val FIREBASE_API_KEY = "AIzaSyCU00GBJ4BPSK5owyaXvHZIXwMJ5Rq5F8c"
|
||||||
|
const val FIREBASE_FUNCTION_BASE_PATH = "https://us-central1-mangamoapp1.cloudfunctions.net/api"
|
||||||
|
const val FIRESTORE_API_BASE_PATH = "https://firestore.googleapis.com/v1/projects/mangamoapp1/databases/(default)/documents"
|
||||||
|
const val FIRESTORE_CACHE_LENGTH = 600
|
||||||
|
|
||||||
|
const val SERIES_QUERY_PARAM = "series"
|
||||||
|
const val CHAPTER_QUERY_PARAM = "chapter"
|
||||||
|
|
||||||
|
const val BROWSE_PAGE_SIZE = 50
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo
|
||||||
|
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.ChapterDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDto
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentDtoInternal
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.DocumentSerializer
|
||||||
|
import eu.kanade.tachiyomi.extension.en.mangamo.dto.SeriesDto
|
||||||
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.decodeFromString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
|
import kotlinx.serialization.serializer
|
||||||
|
import okhttp3.Headers
|
||||||
|
|
||||||
|
class MangamoHelper(headers: Headers) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val json = Json {
|
||||||
|
isLenient = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
explicitNulls = false
|
||||||
|
|
||||||
|
serializersModule = SerializersModule {
|
||||||
|
contextual(DocumentDto::class) { DocumentSerializer(DocumentDtoInternal.serializer(it[0])) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
inline fun <reified T> String.parseJson(): T {
|
||||||
|
return when (T::class) {
|
||||||
|
DocumentDto::class -> json.decodeFromString<T>(
|
||||||
|
DocumentSerializer(serializer<T>() as KSerializer<out DocumentDto<out Any?>>) as KSerializer<T>,
|
||||||
|
this,
|
||||||
|
)
|
||||||
|
else -> json.decodeFromString<T>(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val jsonHeaders = headers.newBuilder()
|
||||||
|
.set("Content-Type", "application/json")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun getCatalogUrl(series: SeriesDto): String {
|
||||||
|
val lowercaseHyphenated = series.name_lowercase!!.replace(' ', '-')
|
||||||
|
return "/catalog/$lowercaseHyphenated"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSeriesUrl(series: SeriesDto): String {
|
||||||
|
return "${getCatalogUrl(series)}?${MangamoConstants.SERIES_QUERY_PARAM}=${series.id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getChapterUrl(chapter: ChapterDto): String {
|
||||||
|
return "?${MangamoConstants.SERIES_QUERY_PARAM}=${chapter.seriesId}&${MangamoConstants.CHAPTER_QUERY_PARAM}=${chapter.id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSeriesStatus(series: SeriesDto): Int =
|
||||||
|
when (series.releaseStatusTag) {
|
||||||
|
"Ongoing" -> SManga.ONGOING
|
||||||
|
"series-complete" -> SManga.COMPLETED
|
||||||
|
"Completed" -> SManga.COMPLETED
|
||||||
|
"Paused" -> SManga.ON_HIATUS
|
||||||
|
else ->
|
||||||
|
if (series.ongoing == true) {
|
||||||
|
SManga.ONGOING
|
||||||
|
} else {
|
||||||
|
SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo
|
||||||
|
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
@Suppress("ClassName")
|
||||||
|
class cachedBy<T>(private val dependencies: () -> Any?, private val callback: () -> T) {
|
||||||
|
private object UNINITIALIZED
|
||||||
|
private var cachedValue: Any? = UNINITIALIZED
|
||||||
|
private var lastDeps: Any? = UNINITIALIZED
|
||||||
|
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
operator fun getValue(thisRef: Any?, property: KProperty<*>): T {
|
||||||
|
synchronized(this) {
|
||||||
|
val newDeps = dependencies()
|
||||||
|
if (newDeps != lastDeps) {
|
||||||
|
lastDeps = newDeps
|
||||||
|
cachedValue = callback()
|
||||||
|
}
|
||||||
|
return cachedValue as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ChapterDto(
|
||||||
|
val id: Int? = null,
|
||||||
|
val chapterNumber: Float? = null,
|
||||||
|
val createdAt: Long? = null,
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
val onlyTransactional: Boolean? = null,
|
||||||
|
val seriesId: Int? = null,
|
||||||
|
)
|
|
@ -0,0 +1,61 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Contextual
|
||||||
|
import kotlinx.serialization.KSerializer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonTransformingSerializer
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class DocumentWrapper<T>(val document: DocumentDto<T>?)
|
||||||
|
|
||||||
|
typealias QueryResultDto<T> = @Serializable List<DocumentWrapper<T>>
|
||||||
|
|
||||||
|
val <T>QueryResultDto<T>.documents
|
||||||
|
get() = mapNotNull { it.document }
|
||||||
|
|
||||||
|
val <T>QueryResultDto<T>.elements
|
||||||
|
get() = mapNotNull { it.document?.fields }
|
||||||
|
|
||||||
|
typealias DocumentDto<T> = @Contextual DocumentDtoInternal<T>
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class DocumentDtoInternal<T>(
|
||||||
|
val fields: T,
|
||||||
|
)
|
||||||
|
|
||||||
|
class DocumentSerializer(dataSerializer: KSerializer<out DocumentDto<out Any?>>) :
|
||||||
|
JsonTransformingSerializer<DocumentDto<Any>>(dataSerializer as KSerializer<DocumentDto<Any>>) {
|
||||||
|
|
||||||
|
override fun transformDeserialize(element: JsonElement): JsonElement {
|
||||||
|
val objMap = element.jsonObject.toMap(HashMap())
|
||||||
|
|
||||||
|
if (objMap.containsKey("fields")) {
|
||||||
|
objMap["fields"] = reduceFieldsObject(objMap["fields"]!!)
|
||||||
|
} else {
|
||||||
|
objMap["fields"] = JsonObject(mapOf())
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonObject(objMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduceFieldsObject(fields: JsonElement): JsonElement {
|
||||||
|
return JsonObject(fields.jsonObject.mapValues { reduceField(it.value) })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reduceField(element: JsonElement): JsonElement {
|
||||||
|
val valueContainer = element.jsonObject.entries.first()
|
||||||
|
|
||||||
|
return when (valueContainer.key) {
|
||||||
|
"arrayValue" -> valueContainer.value.jsonObject["values"]?.jsonArray
|
||||||
|
?.map { reduceField(it) }
|
||||||
|
.let { JsonArray(it ?: listOf()) }
|
||||||
|
"mapValue" -> reduceFieldsObject(valueContainer.value.jsonObject["fields"]!!)
|
||||||
|
else -> valueContainer.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FirebaseAuthDto(
|
||||||
|
val idToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val expiresIn: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class FirebaseRegisterDto(
|
||||||
|
val localId: String,
|
||||||
|
val idToken: String,
|
||||||
|
val refreshToken: String,
|
||||||
|
val expiresIn: Long,
|
||||||
|
)
|
|
@ -0,0 +1,8 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class MangamoLoginDto(
|
||||||
|
val accessToken: String,
|
||||||
|
)
|
|
@ -0,0 +1,10 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class PageDto(
|
||||||
|
val id: Int,
|
||||||
|
val pageNumber: Int,
|
||||||
|
val uri: String,
|
||||||
|
)
|
|
@ -0,0 +1,35 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class SeriesDto(
|
||||||
|
val id: Int? = null,
|
||||||
|
val authors: List<AuthorDto>? = null,
|
||||||
|
val description: String? = null,
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
val genres: List<GenreDto>? = null,
|
||||||
|
val maxFreeChapterNumber: Int? = null,
|
||||||
|
val maxMeteredReadingChapterNumber: Int? = null,
|
||||||
|
val name: String? = null,
|
||||||
|
@Suppress("PropertyName")
|
||||||
|
val name_lowercase: String? = null,
|
||||||
|
val ongoing: Boolean? = null,
|
||||||
|
val onlyOnMangamo: Boolean? = null,
|
||||||
|
val onlyTransactional: Boolean? = null,
|
||||||
|
val releaseStatusTag: String? = null,
|
||||||
|
val titleArt: String? = null,
|
||||||
|
val updatedAt: Long? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class AuthorDto(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class GenreDto(
|
||||||
|
val id: Int,
|
||||||
|
val name: String,
|
||||||
|
)
|
|
@ -0,0 +1,14 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class TokenRefreshDto(
|
||||||
|
@SerialName("expires_in")
|
||||||
|
val expiresIn: Long,
|
||||||
|
@SerialName("id_token")
|
||||||
|
val idToken: String,
|
||||||
|
@SerialName("refresh_token")
|
||||||
|
val refreshToken: String,
|
||||||
|
)
|
|
@ -0,0 +1,8 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.en.mangamo.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class UserDto(
|
||||||
|
val isSubscribed: Boolean? = null,
|
||||||
|
)
|
Loading…
Reference in New Issue