diff --git a/src/all/cosplaytele/build.gradle b/src/all/cosplaytele/build.gradle new file mode 100644 index 000000000..dea28bd86 --- /dev/null +++ b/src/all/cosplaytele/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'CosplayTele' + extClass = '.CosplayTele' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/cosplaytele/res/mipmap-hdpi/ic_launcher.png b/src/all/cosplaytele/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..a81a9ab0b Binary files /dev/null and b/src/all/cosplaytele/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/cosplaytele/res/mipmap-mdpi/ic_launcher.png b/src/all/cosplaytele/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..98662f8a6 Binary files /dev/null and b/src/all/cosplaytele/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/cosplaytele/res/mipmap-xhdpi/ic_launcher.png b/src/all/cosplaytele/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..dc919ccff Binary files /dev/null and b/src/all/cosplaytele/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/cosplaytele/res/mipmap-xxhdpi/ic_launcher.png b/src/all/cosplaytele/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..47a4b3358 Binary files /dev/null and b/src/all/cosplaytele/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/cosplaytele/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/cosplaytele/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..993ad0d40 Binary files /dev/null and b/src/all/cosplaytele/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/cosplaytele/src/eu/kanade/tachiyomi/extension/all/cosplaytele/CosplayTele.kt b/src/all/cosplaytele/src/eu/kanade/tachiyomi/extension/all/cosplaytele/CosplayTele.kt new file mode 100644 index 000000000..48680c5fa --- /dev/null +++ b/src/all/cosplaytele/src/eu/kanade/tachiyomi/extension/all/cosplaytele/CosplayTele.kt @@ -0,0 +1,228 @@ +package eu.kanade.tachiyomi.extension.all.cosplaytele + +import android.util.Log +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.await +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.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import uy.kohesive.injekt.injectLazy +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +class CosplayTele : ParsedHttpSource() { + override val baseUrl = "https://cosplaytele.com" + override val lang = "all" + override val name = "CosplayTele" + override val supportsLatest = true + + private val json: Json by injectLazy() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + // Latest + override fun latestUpdatesFromElement(element: Element): SManga { + val manga = SManga.create() + manga.thumbnail_url = element.selectFirst("img")!!.attr("src") + val linkEl = element.selectFirst("h5 a")!! + manga.title = linkEl.text() + manga.setUrlWithoutDomain(linkEl.attr("abs:href")) + return manga + } + + override fun latestUpdatesNextPageSelector() = ".next.page-number" + override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/page/$page/") + + override fun latestUpdatesSelector() = "div.box" + + // Popular + override fun popularMangaFromElement(element: Element): SManga { + throw UnsupportedOperationException() + } + + override fun popularMangaNextPageSelector(): String? { + throw UnsupportedOperationException() + } + + private val popularPageLimit = 20 + override fun popularMangaRequest(page: Int) = GET("$baseUrl/wp-json/wordpress-popular-posts/v1/popular-posts?offset=${page * popularPageLimit}&limit=$popularPageLimit&range=last7days") + override fun popularMangaSelector(): String = "" + + override fun popularMangaParse(response: Response): MangasPage { + val jsonObject = json.decodeFromString(response.body.string()) + val mangas = jsonObject.map { item -> + val head = item.jsonObject["yoast_head_json"]!!.jsonObject + SManga.create().apply { + title = head["og_title"]!!.jsonPrimitive.content + thumbnail_url = head["og_image"]!!.jsonArray[0].jsonObject["url"]!!.jsonPrimitive.content + setUrlWithoutDomain(head["og_url"]!!.jsonPrimitive.content) + } + } + return MangasPage(mangas, mangas.size >= popularPageLimit) + } + + // Search + override fun searchMangaFromElement(element: Element) = latestUpdatesFromElement(element) + override fun searchMangaNextPageSelector() = latestUpdatesNextPageSelector() + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val filterList = if (filters.isEmpty()) getFilterList() else filters + + val categoryFilter = filterList.findInstance() + return when { + categoryFilter?.state != 0 -> GET( + baseUrl.toHttpUrl().newBuilder().apply { + addPathSegments(categoryFilter!!.toUriPart()) + addPathSegment("page") + addPathSegment(page.toString()) + if (query.isNotEmpty()) { + addQueryParameter("s", query) + } + }.build(), + ) + query.isNotEmpty() -> GET( + "$baseUrl/page/$page/".toHttpUrl().newBuilder().apply { + addQueryParameter("s", query) + }.build(), + ) + else -> latestUpdatesRequest(page) + } + } + + override fun searchMangaSelector() = latestUpdatesSelector() + + // Details + override fun mangaDetailsParse(document: Document): SManga { + val manga = SManga.create() + manga.title = document.select(".entry-title").text() + manga.description = document.select(".entry-title").text() + manga.genre = getTags(document).joinToString(", ") + manga.status = SManga.COMPLETED + return manga + } + + private fun getTags(document: Element): List { + val pattern = """.*/(tag|category)/.*""".toRegex() + return document.select("#main a").filter { a -> pattern.matches(a.attr("href")) }.map { a -> + val link = a.attr("href").split(".com/")[1] + val tag = a.text() + if (tag.isNotEmpty()) { + categories[tag] = link + } + tag + } + } + + override fun chapterFromElement(element: Element): SChapter { + val chapter = SChapter.create() + chapter.setUrlWithoutDomain(element.select("link[rel=\"canonical\"]").attr("href")) + chapter.name = "Gallery" + chapter.date_upload = getDate(element.select("time.updated").attr("datetime")) + return chapter + } + + override fun chapterListSelector() = "html" + + // Pages + override fun pageListParse(document: Document): List { + val pages = mutableListOf() + document.select(".gallery-item img").forEachIndexed { i, it -> + val itUrl = it.attr("src") + pages.add(Page(i, imageUrl = itUrl)) + } + return pages + } + + override fun imageUrlParse(document: Document): String = + throw UnsupportedOperationException() + + // Filters + override fun getFilterList(): FilterList { + CoroutineScope(Dispatchers.IO).launch { fetchFilters() } + val filters = mutableListOf>( + Filter.Header("NOTE: Only one filter will be applied!"), + Filter.Separator(), + UriPartFilter("Category", categories.entries.toTypedArray()), + ) + + if (filtersState == FilterState.Unfetched) { + filters.add(1, Filter.Header("Use 'reset' to load all filters")) + } + return FilterList(filters) + } + + open class UriPartFilter( + displayName: String, + private val valuePair: Array>, + ) : Filter.Select(displayName, valuePair.map { it.key }.toTypedArray()) { + fun toUriPart() = valuePair[state].value + } + + private var categories = mutableMapOf( + Pair("All", ""), + Pair("Cosplay Nude", "category/nude"), + Pair("Cosplay Ero", "category/no-nude"), + Pair("Cosplay", "category/cosplay"), + ) + + private var filtersState = FilterState.Unfetched + private var filterAttempts = 0 + + private enum class FilterState { + Fetching, Fetched, Unfetched + } + + private suspend fun fetchFilters() { + if (filtersState == FilterState.Unfetched && filterAttempts < 3) { + filtersState = FilterState.Fetching + filterAttempts++ + + try { + client.newCall(GET("$baseUrl/explore-categories/", headers)) + .await() + .asJsoup().let { document -> getTags(document) } + filtersState = FilterState.Fetched + } catch (e: Exception) { + Log.e(name, e.stackTraceToString()) + filtersState = FilterState.Unfetched + } + } + } + + private inline fun Iterable<*>.findInstance() = find { it is T } as? T + + private fun getDate(str: String): Long { + try { + val format = str.split("T")[0] + return DATE_FORMAT.parse(format)?.time ?: 0L + } catch (e: ParseException) { + return 0L + } + } + + companion object { + private val DATE_FORMAT by lazy { + SimpleDateFormat("yyyy-MM-dd", Locale.US) + } + } +}