diff --git a/src/en/mangaplanet/build.gradle b/src/en/mangaplanet/build.gradle new file mode 100644 index 000000000..6a8362cfe --- /dev/null +++ b/src/en/mangaplanet/build.gradle @@ -0,0 +1,12 @@ +ext { + extName = "Manga Planet" + extClass = ".MangaPlanet" + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:speedbinb")) +} diff --git a/src/en/mangaplanet/res/mipmap-hdpi/ic_launcher.png b/src/en/mangaplanet/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..815193d28 Binary files /dev/null and b/src/en/mangaplanet/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/mangaplanet/res/mipmap-mdpi/ic_launcher.png b/src/en/mangaplanet/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..dacc282b2 Binary files /dev/null and b/src/en/mangaplanet/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/mangaplanet/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangaplanet/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..325c32802 Binary files /dev/null and b/src/en/mangaplanet/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/mangaplanet/res/mipmap-xxhdpi/ic_launcher.png b/src/en/mangaplanet/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..5ee8a6a3f Binary files /dev/null and b/src/en/mangaplanet/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/mangaplanet/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangaplanet/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4e51aab2d Binary files /dev/null and b/src/en/mangaplanet/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/CookieInterceptor.kt b/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/CookieInterceptor.kt new file mode 100644 index 000000000..9511aff1a --- /dev/null +++ b/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/CookieInterceptor.kt @@ -0,0 +1,46 @@ +package eu.kanade.tachiyomi.extension.en.mangaplanet + +import android.util.Log +import android.webkit.CookieManager +import okhttp3.Interceptor +import okhttp3.Response + +class CookieInterceptor( + private val domain: String, + private val key: String, + private val value: String, +) : Interceptor { + + init { + val url = "https://$domain/" + val cookie = "$key=$value; Domain=$domain; Path=/" + setCookie(url, cookie) + } + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (!request.url.host.endsWith(domain)) return chain.proceed(request) + + val cookie = "$key=$value" + val cookieList = request.header("Cookie")?.split("; ") ?: emptyList() + if (cookie in cookieList) return chain.proceed(request) + + setCookie("https://$domain/", "$cookie; Domain=$domain; Path=/") + val prefix = "$key=" + val newCookie = buildList(cookieList.size + 1) { + cookieList.filterNotTo(this) { it.startsWith(prefix) } + add(cookie) + }.joinToString("; ") + val newRequest = request.newBuilder().header("Cookie", newCookie).build() + return chain.proceed(newRequest) + } + + private fun setCookie(url: String, value: String) { + try { + CookieManager.getInstance().setCookie(url, value) + } catch (e: Exception) { + // Probably running on Tachidesk + Log.e("MangaPlanet", "failed to set cookie", e) + } + } +} diff --git a/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/Filters.kt b/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/Filters.kt new file mode 100644 index 000000000..5ddcd19a6 --- /dev/null +++ b/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/Filters.kt @@ -0,0 +1,133 @@ +package eu.kanade.tachiyomi.extension.en.mangaplanet + +import eu.kanade.tachiyomi.source.model.Filter +import okhttp3.HttpUrl + +interface UrlFilter { + fun addToUrl(builder: HttpUrl.Builder) +} + +class SortFilter : SelectFilter( + "Sort order", + "sort", + arrayOf( + Pair("Released last", ""), + Pair("Released first", "1"), + Pair("By A to Z", "2"), + ), +) + +class CategoryFilter : MultiSelectFilter( + "Category", + "cat", + listOf( + MultiSelectOption("Shojo/Josei", "3"), + MultiSelectOption("Shonen/Seinen", "1"), + MultiSelectOption("BL(futekiya)", "2"), + MultiSelectOption("GL/Yuri", "4"), + ), +) + +class SpicyLevelFilter : MultiSelectFilter( + "Spicy Level - BL(futekiya) only", + "hp", + listOf( + MultiSelectOption("🌶️🌶️🌶️🌶️🌶️", "5"), + MultiSelectOption("🌶️🌶️🌶️🌶️️", "4"), + MultiSelectOption("🌶️🌶️🌶️", "3"), + MultiSelectOption("🌶️🌶️", "2"), + MultiSelectOption("🌶️", "1"), + ), +) + +class AccessTypeFilter : SelectFilter( + "Access Type", + "bt", + arrayOf( + Pair("All", ""), + Pair("Access for free", "1"), + Pair("Access via Points", "2"), + Pair("Access via Manga Planet Pass", "3"), + ), +) + +class FormatFilter : MultiSelectFilter( + "Format", + "fmt", + listOf( + MultiSelectOption("Manga", "1"), + MultiSelectOption("TatéManga", "2"), + MultiSelectOption("Novel", "3"), // Novels are images with text + ), +) + +class RatingFilter : MultiSelectFilter( + "Rating", + "rtg", + listOf( + MultiSelectOption("All Ages", "0"), + MultiSelectOption("R16+", "16"), + MultiSelectOption("R18+", "18"), + ), +) + +class ReleaseStatusFilter : SelectFilter( + "Release status", + "comp", + arrayOf( + Pair("All", ""), + Pair("Ongoing", "progress"), + Pair("Completed", "comp"), + ), +) + +class LetterFilter : SelectFilter( + "Display by First Letter", + "fl", + buildList { + add(Pair("All", "")) + + for (letter in 'A'..'Z') { + add(Pair(letter.toString(), letter.toString())) + } + + add(Pair("Other", "other")) + } + .toTypedArray(), +) + +open class MultiSelectFilter( + name: String, + val queryParameter: String, + options: List, +) : Filter.Group(name, options), UrlFilter { + override fun addToUrl(builder: HttpUrl.Builder) { + val enabled = state.filter { it.state } + + if (enabled.isEmpty() || enabled.size == state.size) { + return + } + + builder.addQueryParameter( + queryParameter, + enabled.joinToString(",") { it.value }, + ) + } +} + +class MultiSelectOption(name: String, val value: String, state: Boolean = false) : Filter.CheckBox(name, state) + +open class SelectFilter( + name: String, + val queryParameter: String, + val vals: Array>, + state: Int = 0, +) : Filter.Select(name, vals.map { it.first }.toTypedArray(), state), UrlFilter { + override fun addToUrl(builder: HttpUrl.Builder) { + if (state == 0) { + return + } + + builder.addQueryParameter(queryParameter, vals[state].second) + } +} diff --git a/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/MangaPlanet.kt b/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/MangaPlanet.kt new file mode 100644 index 000000000..27be22be6 --- /dev/null +++ b/src/en/mangaplanet/src/eu/kanade/tachiyomi/extension/en/mangaplanet/MangaPlanet.kt @@ -0,0 +1,194 @@ +package eu.kanade.tachiyomi.extension.en.mangaplanet + +import eu.kanade.tachiyomi.lib.speedbinb.SpeedBinbInterceptor +import eu.kanade.tachiyomi.lib.speedbinb.SpeedBinbReader +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.FilterList +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.serialization.json.Json +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.Injekt +import uy.kohesive.injekt.api.get +import java.text.SimpleDateFormat +import java.util.Locale + +class MangaPlanet : ParsedHttpSource() { + + override val name = "Manga Planet" + + override val baseUrl = "https://mangaplanet.com" + + override val lang = "en" + + override val supportsLatest = false + + // No need to be lazy if you're going to use it immediately below. + private val json = Injekt.get() + + override val client = network.client.newBuilder() + .addInterceptor(SpeedBinbInterceptor(json)) + .addInterceptor(CookieInterceptor(baseUrl.toHttpUrl().host, "mpaconf", "18")) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/browse/title?ttlpage=$page", headers) + + override fun popularMangaSelector() = ".book-list" + + override fun popularMangaFromElement(element: Element) = SManga.create().apply { + setUrlWithoutDomain(element.selectFirst("a")!!.attr("href")) + title = element.selectFirst("h3")!!.text() + author = element.selectFirst("p:has(.fa-pen-nib)")?.text() + description = element.selectFirst("h3 + p")?.text() + thumbnail_url = element.selectFirst("img")?.absUrl("data-src") + status = when { + element.selectFirst(".fa-flag-alt") != null -> SManga.COMPLETED + element.selectFirst(".fa-arrow-right") != null -> SManga.ONGOING + else -> SManga.UNKNOWN + } + } + + override fun popularMangaNextPageSelector() = "ul.pagination a.page-link[rel=next]" + + override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException() + + override fun latestUpdatesSelector() = throw UnsupportedOperationException() + + override fun latestUpdatesFromElement(element: Element) = throw UnsupportedOperationException() + + override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException() + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + if (query.isNotEmpty()) { + addPathSegment("search") + addQueryParameter("keyword", query) + } else { + addPathSegments("browse/title") + } + + filters.ifEmpty { getFilterList() } + .filterIsInstance() + .forEach { it.addToUrl(this) } + + if (page > 1) { + addQueryParameter("ttlpage", page.toString()) + } + }.build() + + return GET(url, headers) + } + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) + + override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() + + override fun mangaDetailsParse(document: Document): SManga { + val alternativeTitles = document.selectFirst("h3#manga_title + p")!! + .textNodes() + .filterNot { it.text().isBlank() } + .joinToString { it.text() } + + return SManga.create().apply { + title = document.selectFirst("h3#manga_title")!!.text() + author = document.select("h3:has(.fa-pen-nib) a").joinToString { it.text() } + description = buildString { + append("Alternative Titles: ") + appendLine(alternativeTitles) + appendLine() + appendLine(document.selectFirst("h3#manga_title ~ p:eq(2)")!!.text()) + } + genre = buildList { + document.select("h3:has(.fa-layer-group) a") + .map { it.text() } + .let { addAll(it) } + document.select(".fa-pepper-hot").size + .takeIf { it > 0 } + ?.let { add("🌶️".repeat(it)) } + document.select(".tags-btn button") + .map { it.text() } + .let { addAll(it) } + document.selectFirst("span:has(.fa-book-spells, .fa-book)")?.let { add(it.text()) } + document.selectFirst("span:has(.fa-user-friends)")?.let { add(it.text()) } + } + .joinToString() + status = when { + document.selectFirst(".fa-flag-alt") != null -> SManga.COMPLETED + document.selectFirst(".fa-arrow-right") != null -> SManga.ONGOING + else -> SManga.UNKNOWN + } + thumbnail_url = document.selectFirst("img.img-thumbnail")?.absUrl("data-src") + } + } + + override fun chapterListSelector() = "ul.ep_ul li.list-group-item" + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + element.selectFirst("h3 p")!!.let { + val id = it.id().substringAfter("epi_title_") + + url = "/reader?cid=$id" + name = it.text() + } + + date_upload = try { + val date = element.selectFirst("p")!!.ownText() + + dateFormat.parse(date)!!.time + } catch (_: Exception) { + 0L + } + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + + return document.select(chapterListSelector()) + .filter { e -> + e.selectFirst("p")?.ownText()?.contains("Arrives on") != true + } + .map { chapterFromElement(it) } + .reversed() + } + + private val reader by lazy { SpeedBinbReader(client, headers, json) } + + override fun pageListParse(document: Document): List { + if (document.selectFirst("a[href\$=account/sign-up]") != null) { + throw Exception("Sign up in WebView to read this chapter") + } + + if (document.selectFirst("a:contains(UNLOCK NOW)") != null) { + throw Exception("Purchase this chapter in WebView") + } + + return reader.pageListParse(document) + } + + override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() + + override fun getFilterList() = FilterList( + SortFilter(), + AccessTypeFilter(), + ReleaseStatusFilter(), + LetterFilter(), + CategoryFilter(), + SpicyLevelFilter(), + FormatFilter(), + RatingFilter(), + ) +} + +private val dateFormat = SimpleDateFormat("MMMM d, yyyy", Locale.ENGLISH)