diff --git a/src/all/tappytoon/AndroidManifest.xml b/src/all/tappytoon/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/all/tappytoon/AndroidManifest.xml @@ -0,0 +1,2 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest package="eu.kanade.tachiyomi.extension" /> diff --git a/src/all/tappytoon/build.gradle b/src/all/tappytoon/build.gradle new file mode 100644 index 000000000..e96973b1d --- /dev/null +++ b/src/all/tappytoon/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Tappytoon' + pkgNameSuffix = 'all.tappytoon' + extClass = '.TappytoonFactory' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/tappytoon/res/mipmap-hdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..4384fe137 Binary files /dev/null and b/src/all/tappytoon/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/tappytoon/res/mipmap-mdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..b558c71fa Binary files /dev/null and b/src/all/tappytoon/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/tappytoon/res/mipmap-xhdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..e9cd0968f Binary files /dev/null and b/src/all/tappytoon/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/tappytoon/res/mipmap-xxhdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..ebcd17c91 Binary files /dev/null and b/src/all/tappytoon/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/tappytoon/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/tappytoon/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..2126158ce Binary files /dev/null and b/src/all/tappytoon/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/tappytoon/res/web_hi_res_512.png b/src/all/tappytoon/res/web_hi_res_512.png new file mode 100644 index 000000000..c245a69e2 Binary files /dev/null and b/src/all/tappytoon/res/web_hi_res_512.png differ diff --git a/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/Tappytoon.kt b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/Tappytoon.kt new file mode 100644 index 000000000..2614be339 --- /dev/null +++ b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/Tappytoon.kt @@ -0,0 +1,232 @@ +package eu.kanade.tachiyomi.extension.all.tappytoon + +import android.util.Log +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.Filter +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 eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale + +class Tappytoon(override val lang: String) : HttpSource() { + override val name = "Tappytoon" + + override val baseUrl = "https://www.tappytoon.com/$lang" + + override val supportsLatest = true + + override val client = network.client.newBuilder().addInterceptor { chain -> + val res = chain.proceed(chain.request()) + if (res.isSuccessful) return@addInterceptor res + // Log JSON error if available + if (res.headers["Content-Type"] == "application/json") { + res.body?.string()?.let(json::parseToJsonElement)?.run { + val type = jsonObject["type"]?.jsonPrimitive?.content + val msg = jsonObject["message"]!!.jsonPrimitive.content + Log.e("Tappytoon", "${type ?: "Error"} - $msg") + } + } else { + res.close() + } + when (val code = res.code) { + 403 -> throw Error("You are not authorized to view this") + else -> throw Error("HTTP error $code") + } + }.build() + + private val json by injectLazy<Json>() + + private val apiHeaders by lazy { + val res = client.newCall(GET(baseUrl, headers)).execute() + val data = res.asJsoup().getElementById("__NEXT_DATA__") + val obj = json.parseToJsonElement(data.data()) + .jsonObject["props"]!!.jsonObject["initialState"]!! + .jsonObject["axios"]!!.jsonObject["headers"]!!.jsonObject + val auth = obj["Authorization"]!!.jsonPrimitive.content + val uuid = obj["X-Device-Uuid"]!!.jsonPrimitive.content + headers.newBuilder() + .set("Origin", "https://www.tappytoon.com") + .set("Referer", "https://www.tappytoon.com/") + .set("Accept-Language", lang) + .set("Authorization", auth) + .set("X-Device-Uuid", uuid) + .build() + } + + private var nextUrl: String? = null + + override fun headersBuilder() = + super.headersBuilder().set("Referer", "https://www.tappytoon.com/") + + override fun latestUpdatesRequest(page: Int) = + apiUrl.newBuilder().run { + addEncodedPathSegment("comics") + addEncodedQueryParameter("day_of_week", day) + addEncodedQueryParameter("locale", lang) + GET(toString(), apiHeaders) + } + + override fun popularMangaRequest(page: Int) = + apiUrl.newBuilder().run { + addEncodedPathSegment("comics") + addEncodedQueryParameter("sort_by", "trending") + // Sort is only available for completed series + addEncodedQueryParameter("filter", "completed") + addEncodedQueryParameter("locale", lang) + GET(toString(), apiHeaders) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (nextUrl != null) return GET(nextUrl!!, apiHeaders) + val url = apiUrl.newBuilder() + .addEncodedPathSegments("comics") + .addEncodedQueryParameter("locale", lang) + val genre = filters.find { it is Genre } as? Genre + if (genre != null && genre.state != 0) { + url.addEncodedQueryParameter("genre", genre.alias) + url.addEncodedQueryParameter("limit", "50") + } else if (query.isNotBlank()) { + url.addQueryParameter("keyword", query) + } + return GET(url.toString(), apiHeaders) + } + + // Request the real URL for the webview + override fun mangaDetailsRequest(manga: SManga) = + GET("$baseUrl/comics/${manga.slug}", headers) + + override fun chapterListRequest(manga: SManga) = + apiUrl.newBuilder().run { + addEncodedPathSegments("comics/${manga.id}/chapters") + addEncodedQueryParameter("locale", lang) + GET(toString(), apiHeaders) + } + + override fun pageListRequest(chapter: SChapter) = + apiUrl.newBuilder().run { + addEncodedPathSegments("chapters/${chapter.url}") + addEncodedQueryParameter("includes", "images") + addEncodedQueryParameter("locale", lang) + GET(toString(), apiHeaders) + } + + override fun latestUpdatesParse(response: Response) = + response.parse<List<Comic>>().accessible.map { + SManga.create().apply { + url = it.toString() + title = it.title + description = it.longDescription + thumbnail_url = it.posterThumbnailUrl + author = it.authors.joinToString() + artist = author + genre = buildString { + it.genres.joinToString(this, postfix = ", ") + append("Rating: ").append(it.ageRating) + } + status = when { + it.isCompleted -> SManga.COMPLETED + !it.isHiatus -> SManga.ONGOING + else -> SManga.UNKNOWN + } + } + }.run { MangasPage(this, false) } + + override fun popularMangaParse(response: Response) = + latestUpdatesParse(response) + + override fun searchMangaParse(response: Response) = + response.headers["Link"].let { + nextUrl = it?.substringAfter('<')?.substringBefore('>') + latestUpdatesParse(response).copy(hasNextPage = it != null) + } + + override fun chapterListParse(response: Response) = + response.parse<List<Chapter>>().accessible.map { + SChapter.create().apply { + name = it.toString() + url = it.id.toString() + chapter_number = it.order + date_upload = dateFormat.parse(it.createdAt)?.time ?: 0L + } + } + + override fun pageListParse(response: Response) = + response.parse<Images>().mapIndexed { idx, img -> + Page(idx, "", img.toString()) + } + + override fun fetchMangaDetails(manga: SManga) = + rx.Observable.just(manga.apply { initialized = true })!! + + override fun getFilterList() = FilterList( + Filter.Header("NOTE: can't be used with text search!"), + Genre(genres.keys.toTypedArray()) + ) + + private inline fun <reified T> Response.parse() = + json.decodeFromJsonElement<T>(json.parseToJsonElement(body!!.string())) + + class Genre(values: Array<String>) : Filter.Select<String>("Genre", values) + + private inline val Genre.alias: String + get() = genres[values[state]]!! + + private inline val SManga.slug: String + get() = url.substringBefore('|') + + private inline val SManga.id: String + get() = url.substringAfter('|') + + override fun mangaDetailsParse(response: Response) = + throw UnsupportedOperationException("Not used") + + override fun imageUrlParse(response: Response) = + throw UnsupportedOperationException("Not used") + + companion object { + private val apiUrl = "https://api-global.tappytoon.com".toHttpUrl() + + private val genres = mapOf( + "<select>" to "", + "Action" to "action", + "Romance" to "romance", + "Fantasy" to "fantasy", + "School" to "school", + "Slice of Life" to "slice", + "BL" to "bl", + "Comedy" to "comedy", + "GL" to "gl" + ) + + private val dateFormat by lazy { + SimpleDateFormat("yyyy-MM-d'T'HH:mm:ss", Locale.ROOT) + } + + private val day by lazy { + when (Calendar.getInstance()[Calendar.DAY_OF_WEEK]) { + Calendar.SUNDAY -> "sun" + Calendar.MONDAY -> "mon" + Calendar.TUESDAY -> "tue" + Calendar.WEDNESDAY -> "wed" + Calendar.THURSDAY -> "thu" + Calendar.FRIDAY -> "fri" + Calendar.SATURDAY -> "sat" + else -> error("What day is it?") + } + } + } +} diff --git a/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonAPI.kt b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonAPI.kt new file mode 100644 index 000000000..2540a46eb --- /dev/null +++ b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonAPI.kt @@ -0,0 +1,64 @@ +package eu.kanade.tachiyomi.extension.all.tappytoon + +import kotlinx.serialization.Serializable + +interface Accessible { + val isAccessible: Boolean +} + +inline val <A : Accessible> List<A>.accessible: List<A> + get() = filter { it.isAccessible } + +@Serializable +class Comic( + private val id: Int, + val title: String, + private val slug: String, + val longDescription: String, + val posterThumbnailUrl: String, + val isHiatus: Boolean, + override val isAccessible: Boolean, + val isCompleted: Boolean, + val ageRating: Name, + val genres: List<Name>, + val authors: List<Name> +) : Accessible { + override fun toString() = "$slug|$id" +} + +@Serializable +class Name(private val name: String) { + override fun toString() = name +} + +@Serializable +class Chapter( + val id: Int, + val order: Float, + private val title: String, + private val subtitle: String, + override val isAccessible: Boolean, + private val isFree: Boolean, + private val isUserUnlocked: Boolean, + private val isUserRented: Boolean, + val createdAt: String +) : Accessible { + override fun toString() = buildString { + append(title) + if (subtitle.isNotEmpty()) { + append(" - ") + append(subtitle) + } + if (!isFree && !(isUserUnlocked || isUserRented)) { + append(" \uD83D\uDD12") + } + } +} + +@Serializable +class Images(private val images: List<URL>) : List<URL> by images + +@Serializable +class URL(private val url: String) { + override fun toString() = url +} diff --git a/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonFactory.kt b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonFactory.kt new file mode 100644 index 000000000..c6b7b1840 --- /dev/null +++ b/src/all/tappytoon/src/eu/kanade/tachiyomi/extension/all/tappytoon/TappytoonFactory.kt @@ -0,0 +1,9 @@ +package eu.kanade.tachiyomi.extension.all.tappytoon + +import eu.kanade.tachiyomi.source.SourceFactory + +class TappytoonFactory : SourceFactory { + private val langs = setOf("en", "fr", "de") + + override fun createSources() = langs.map(::Tappytoon) +}