Add ability to read previously paid chapters in TTP (#10388)

* Add hability to read paid chapters in TTP.

* Fix formating.

* Remove promotional thumbnails.
This commit is contained in:
Alessandro Jean 2022-01-09 13:25:52 -03:00 committed by GitHub
parent fa7e1e75d0
commit 662bd7d1c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 89 additions and 37 deletions

View File

@ -6,7 +6,7 @@ ext {
extName = 'TOPTOON+' extName = 'TOPTOON+'
pkgNameSuffix = 'en.toptoonplus' pkgNameSuffix = 'en.toptoonplus'
extClass = '.TopToonPlus' extClass = '.TopToonPlus'
extVersionCode = 1 extVersionCode = 2
isNsfw = true isNsfw = true
} }

View File

@ -4,9 +4,9 @@ import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.text.InputType import android.text.InputType
import android.util.Base64 import android.util.Base64
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference import androidx.preference.EditTextPreference
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
@ -36,7 +36,6 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.io.IOException import java.io.IOException
import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
@ -81,6 +80,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
private val deviceId: String by lazy { UUID.randomUUID().toString() } private val deviceId: String by lazy { UUID.randomUUID().toString() }
private var token: String? = null private var token: String? = null
private var userMature: Boolean = false private var userMature: Boolean = false
override fun headersBuilder(): Headers.Builder = Headers.Builder() override fun headersBuilder(): Headers.Builder = Headers.Builder()
@ -97,7 +97,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
} }
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response): MangasPage {
val result = json.decodeFromString<TopToonResult<TopToonRanking>>(response.body!!.string()) val result = response.parseAs<TopToonRanking>()
if (result.data == null) { if (result.data == null) {
return MangasPage(emptyList(), hasNextPage = false) return MangasPage(emptyList(), hasNextPage = false)
@ -110,7 +110,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
private fun popularMangaFromObject(comic: TopToonComic) = SManga.create().apply { private fun popularMangaFromObject(comic: TopToonComic) = SManga.create().apply {
title = comic.information?.title.orEmpty() title = comic.information?.title.orEmpty()
thumbnail_url = comic.thumbnailImage?.jpeg?.firstOrNull()?.path.orEmpty() thumbnail_url = comic.firstAvailableThumbnail
url = "/comic/${comic.comicId}" url = "/comic/${comic.comicId}"
} }
@ -124,7 +124,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
} }
override fun latestUpdatesParse(response: Response): MangasPage { override fun latestUpdatesParse(response: Response): MangasPage {
val result = json.decodeFromString<TopToonResult<TopToonDaily>>(response.body!!.string()) val result = response.parseAs<TopToonDaily>()
if (result.data == null) { if (result.data == null) {
return MangasPage(emptyList(), hasNextPage = false) return MangasPage(emptyList(), hasNextPage = false)
@ -161,7 +161,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
return popularMangaParse(response) return popularMangaParse(response)
} }
val result = json.decodeFromString<TopToonResult<List<TopToonComic>>>(response.body!!.string()) val result = response.parseAs<List<TopToonComic>>()
if (result.data == null) { if (result.data == null) {
return MangasPage(emptyList(), hasNextPage = false) return MangasPage(emptyList(), hasNextPage = false)
@ -203,7 +203,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
} }
override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply { override fun mangaDetailsParse(response: Response): SManga = SManga.create().apply {
val result = json.decodeFromString<TopToonResult<TopToonDetails>>(response.body!!.string()) val result = response.parseAs<TopToonDetails>()
if (result.data == null) { if (result.data == null) {
throw Exception(COULD_NOT_PARSE_RESPONSE) throw Exception(COULD_NOT_PARSE_RESPONSE)
@ -212,23 +212,23 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
val comic = result.data.comic!! val comic = result.data.comic!!
title = comic.information?.title.orEmpty() title = comic.information?.title.orEmpty()
thumbnail_url = comic.thumbnailImage?.jpeg?.firstOrNull()?.path.orEmpty() thumbnail_url = comic.firstAvailableThumbnail
description = comic.information?.description description = comic.information?.description
status = SManga.ONGOING genre = comic.genres
author = comic.author.joinToString(", ") { it.trim() } status = if (result.data.isCompleted) SManga.COMPLETED else SManga.ONGOING
author = comic.author.joinToString { it.trim() }
} }
override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga.url) override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga.url)
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val result = json.decodeFromString<TopToonResult<TopToonDetails>>(response.body!!.string()) val result = response.parseAs<TopToonDetails>()
if (result.data == null) { if (result.data == null) {
throw Exception(COULD_NOT_PARSE_RESPONSE) throw Exception(COULD_NOT_PARSE_RESPONSE)
} }
return result.data.episode return result.data.availableEpisodes
.filter { episode -> episode.information?.payType == 0 }
.map(::chapterFromObject) .map(::chapterFromObject)
.reversed() .reversed()
} }
@ -266,7 +266,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
} }
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
val result = json.decodeFromString<TopToonResult<TopToonUsableEpisode>>(response.body!!.string()) val result = response.parseAs<TopToonUsableEpisode>()
if (result.data == null) { if (result.data == null) {
throw Exception(COULD_NOT_PARSE_RESPONSE) throw Exception(COULD_NOT_PARSE_RESPONSE)
@ -274,17 +274,32 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
val usableEpisode = result.data val usableEpisode = result.data
if (usableEpisode.isFree.not() || if (usableEpisode.isFree.not() && usableEpisode.isOwn.not()) {
usableEpisode.episodePrice?.payType != 0 ||
usableEpisode.purchaseMethod.firstOrNull() != "FREE_EPISODE"
) {
throw Exception(CHAPTER_NOT_FREE) throw Exception(CHAPTER_NOT_FREE)
} }
return usableEpisode.episode!!.contentImage?.jpeg.orEmpty() val viewerRequest = viewerRequest(usableEpisode.comicId, usableEpisode.episodeId)
.mapIndexed { i, page -> val viewerResponse = client.newCall(viewerRequest).execute()
Page(i, baseUrl, page.path) val viewerResult = viewerResponse.parseAs<TopToonDetails>()
}
return viewerResult.data!!.episode
.find { episode -> episode.episodeId == usableEpisode.episodeId }
.let { episode -> episode?.contentImage?.jpeg.orEmpty() }
.mapIndexed { i, page -> Page(i, baseUrl, page.path) }
}
private fun viewerRequest(comicId: Int, episodeId: Int): Request {
val newHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("X-Api-Key", API_KEY)
.build()
val apiUrl = "$API_URL/api/v1/page/viewer".toHttpUrl().newBuilder()
.addQueryParameter("comicId", comicId.toString())
.addQueryParameter("episodeId", episodeId.toString())
.toString()
return GET(apiUrl, newHeaders)
} }
override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.imageUrl!!) override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.imageUrl!!)
@ -308,6 +323,10 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
summary = EMAIL_PREF_SUMMARY summary = EMAIL_PREF_SUMMARY
dialogTitle = EMAIL_PREF_TITLE dialogTitle = EMAIL_PREF_TITLE
setOnBindEditTextListener {
it.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
}
setOnPreferenceChangeListener { _, newValue -> setOnPreferenceChangeListener { _, newValue ->
token = null token = null
@ -337,7 +356,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
} }
} }
val maturePref = CheckBoxPreference(screen.context).apply { val maturePref = SwitchPreferenceCompat(screen.context).apply {
key = MATURE_PREF_KEY key = MATURE_PREF_KEY
title = MATURE_PREF_TITLE title = MATURE_PREF_TITLE
setDefaultValue(MATURE_PREF_DEFAULT) setDefaultValue(MATURE_PREF_DEFAULT)
@ -377,11 +396,13 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
} }
val newRequest = chain.request().newBuilder() val newRequest = chain.request().newBuilder()
.removeHeader("Token")
.addHeader("Token", token.orEmpty().ifEmpty { "null" })
.build()
return chain.proceed(newRequest) if (token.orEmpty().isNotEmpty()) {
newRequest.removeHeader("Token")
.addHeader("Token", token!!)
}
return chain.proceed(newRequest.build())
} }
return chain.proceed(chain.request()) return chain.proceed(chain.request())
@ -413,7 +434,7 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
throw IOException(COULD_NOT_LOGIN) throw IOException(COULD_NOT_LOGIN)
} }
val result = json.decodeFromString<TopToonResult<TopToonAuth>>(response.body!!.string()) val result = response.parseAs<TopToonAuth>()
if (result.data == null) { if (result.data == null) {
throw IOException(COULD_NOT_LOGIN) throw IOException(COULD_NOT_LOGIN)
@ -443,16 +464,18 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
return POST("$API_URL/users/setUser", newHeaders, requestBody, CacheControl.FORCE_NETWORK) return POST("$API_URL/users/setUser", newHeaders, requestBody, CacheControl.FORCE_NETWORK)
} }
private inline fun <reified T> Response.parseAs(): TopToonResult<T> = use {
json.decodeFromString(it.body?.string().orEmpty())
}
private fun String.toDate(): Long { private fun String.toDate(): Long {
return try { return runCatching { DATE_FORMATTER.parse(this)?.time }
DATE_FORMATTER.parse(this)?.time ?: 0L .getOrNull() ?: 0L
} catch (e: ParseException) {
0L
}
} }
companion object { companion object {
private const val API_URL = "https://api.toptoonplus.com" private const val API_URL = "https://api.toptoonplus.com"
private val API_KEY by lazy { private val API_KEY by lazy {
Base64.decode("U1VQRVJDT09MQVBJS0VZMjAyMSNAIyg=", Base64.DEFAULT) Base64.decode("U1VQRVJDT09MQVBJS0VZMjAyMSNAIyg=", Base64.DEFAULT)
.toString(charset("UTF-8")) .toString(charset("UTF-8"))
@ -480,6 +503,8 @@ class TopToonPlus : HttpSource(), ConfigurableSource {
private const val MATURE_PREF_SUMMARY = "This setting only takes effect if you are signed in." private const val MATURE_PREF_SUMMARY = "This setting only takes effect if you are signed in."
private const val MATURE_PREF_DEFAULT = false private const val MATURE_PREF_DEFAULT = false
private val DATE_FORMATTER by lazy { SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH) } private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
}
} }
} }

View File

@ -22,13 +22,26 @@ data class TopToonDaily(
data class TopToonDetails( data class TopToonDetails(
val comic: TopToonComic? = null, val comic: TopToonComic? = null,
val episode: List<TopToonEpisode> = emptyList() val episode: List<TopToonEpisode> = emptyList()
) ) {
val availableEpisodes: List<TopToonEpisode>
get() = episode.filter { it.information?.payType == 0 || it.isPurchased == 1 }
val isCompleted: Boolean
get() = episode.lastOrNull()?.information
?.let {
it.title.contains("[End]", true) ||
it.subTitle.contains("[End]", true)
} ?: false
}
@Serializable @Serializable
data class TopToonUsableEpisode( data class TopToonUsableEpisode(
val comicId: Int = 0,
val episode: TopToonEpisode? = null, val episode: TopToonEpisode? = null,
val episodeId: Int = 0,
val episodePrice: TopToonEpisodePrice? = null, val episodePrice: TopToonEpisodePrice? = null,
val isFree: Boolean = false, val isFree: Boolean = false,
val isOwn: Boolean = false,
val needLogin: Boolean = false, val needLogin: Boolean = false,
val purchaseMethod: List<String> = emptyList() val purchaseMethod: List<String> = emptyList()
) )
@ -42,9 +55,22 @@ data class TopToonEpisodePrice(
data class TopToonComic( data class TopToonComic(
val author: List<String> = emptyList(), val author: List<String> = emptyList(),
val comicId: Int = -1, val comicId: Int = -1,
val hashtags: List<String> = emptyList(),
val information: TopToonComicInfo? = null, val information: TopToonComicInfo? = null,
val thumbnailImage: TopToonComicPoster? = null, val thumbnailImage: TopToonComicPoster? = null,
) val titleVerticalThumbnail: TopToonComicPoster? = null
) {
val firstAvailableThumbnail: String
get() = titleVerticalThumbnail?.jpeg?.firstOrNull()?.path
?: thumbnailImage!!.jpeg.firstOrNull()?.path.orEmpty()
val genres: String
get() = hashtags
.flatMap { it.split("&") }
.map(String::trim)
.sorted()
.joinToString()
}
@Serializable @Serializable
data class TopToonComicInfo( data class TopToonComicInfo(
@ -69,6 +95,7 @@ data class TopToonEpisode(
val contentImage: TopToonComicPoster? = null, val contentImage: TopToonComicPoster? = null,
val episodeId: Int = -1, val episodeId: Int = -1,
val information: TopToonEpisodeInfo? = null, val information: TopToonEpisodeInfo? = null,
val isPurchased: Int = 0,
val order: Int = -1 val order: Int = -1
) )