diff --git a/src/all/mangaball/AndroidManifest.xml b/src/all/mangaball/AndroidManifest.xml
new file mode 100644
index 000000000..5eff26df2
--- /dev/null
+++ b/src/all/mangaball/AndroidManifest.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/all/mangaball/build.gradle b/src/all/mangaball/build.gradle
new file mode 100644
index 000000000..1469f3c38
--- /dev/null
+++ b/src/all/mangaball/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'Manga Ball'
+ extClass = '.MangaBallFactory'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/all/mangaball/res/mipmap-hdpi/ic_launcher.png b/src/all/mangaball/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..03854c11d
Binary files /dev/null and b/src/all/mangaball/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/all/mangaball/res/mipmap-mdpi/ic_launcher.png b/src/all/mangaball/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..c1562e10b
Binary files /dev/null and b/src/all/mangaball/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/all/mangaball/res/mipmap-xhdpi/ic_launcher.png b/src/all/mangaball/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..c69593789
Binary files /dev/null and b/src/all/mangaball/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/all/mangaball/res/mipmap-xxhdpi/ic_launcher.png b/src/all/mangaball/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..21c53e08a
Binary files /dev/null and b/src/all/mangaball/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/all/mangaball/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/mangaball/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..679052f9c
Binary files /dev/null and b/src/all/mangaball/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/Dto.kt b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/Dto.kt
new file mode 100644
index 000000000..82b6fbf5e
--- /dev/null
+++ b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/Dto.kt
@@ -0,0 +1,71 @@
+package eu.kanade.tachiyomi.extension.all.mangaball
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+class SearchResponse(
+ val data: List,
+ private val pagination: Pagination,
+) {
+ @Serializable
+ class Pagination(
+ @SerialName("current_page")
+ val currentPage: Int,
+ @SerialName("last_page")
+ val lastPage: Int,
+ )
+
+ fun hasNextPage() = pagination.currentPage < pagination.lastPage
+}
+
+@Serializable
+class SearchManga(
+ val url: String,
+ val name: String,
+ val cover: String,
+ val isAdult: Boolean,
+)
+
+@Serializable
+class ChapterListResponse(
+ @SerialName("ALL_CHAPTERS")
+ val chapters: List,
+)
+
+@Serializable
+class ChapterContainer(
+ @SerialName("number_float")
+ val number: Float,
+ val translations: List,
+)
+
+@Serializable
+class Chapter(
+ val id: String,
+ val name: String,
+ val language: String,
+ val group: Group,
+ val date: String,
+ val volume: Int,
+)
+
+@Serializable
+class Group(
+ @SerialName("_id")
+ val id: String,
+ val name: String,
+)
+
+@Serializable
+class Yoast(
+ @SerialName("@graph")
+ val graph: List,
+) {
+ @Serializable
+ class Graph(
+ @SerialName("@type")
+ val type: String,
+ val url: String? = null,
+ )
+}
diff --git a/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/Filters.kt b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/Filters.kt
new file mode 100644
index 000000000..c4c646768
--- /dev/null
+++ b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/Filters.kt
@@ -0,0 +1,201 @@
+package eu.kanade.tachiyomi.extension.all.mangaball
+
+import eu.kanade.tachiyomi.source.model.Filter
+
+abstract class SelectFilter(
+ name: String,
+ private val options: List>,
+) : Filter.Select(
+ name,
+ options.map { it.first }.toTypedArray(),
+) {
+ val selected get() = options[state].second
+}
+
+class TriStateFilter(name: String, val value: T) : Filter.TriState(name)
+
+abstract class TriStateGroupFilter(
+ name: String,
+ options: List>,
+) : Filter.Group>(
+ name,
+ options.map { TriStateFilter(it.first, it.second) },
+) {
+ val included get() = state.filter { it.isIncluded() }.map { it.value }
+ val excluded get() = state.filter { it.isExcluded() }.map { it.value }
+}
+
+class SortFilter : SelectFilter(
+ "Sort By",
+ options = listOf(
+ "Lastest Updated Chapters" to "updated_chapters_desc",
+ "Oldest Updated Chapters" to "updated_chapters_asc",
+ "Lastest Created" to "created_at_desc",
+ "Oldest Created" to "created_at_asc",
+ "Title A-Z" to "name_asc",
+ "Title Z-A" to "name_desc",
+ "Views High to Low" to "views_desc",
+ "Views Low to High" to "views_asc",
+ ),
+)
+
+class ContentFilter : TriStateGroupFilter(
+ "Content",
+ options = listOf(
+ "Gore" to "685148d115e8b86aae68e4f3",
+ "Sexual Violence" to "685146c5f3ed681c80f257e7",
+ ),
+)
+
+class FormatFilter : TriStateGroupFilter(
+ "Format",
+ options = listOf(
+ "4-Koma" to "685148d115e8b86aae68e4ec",
+ "Adaptation" to "685148cf15e8b86aae68e4de",
+ "Anthology" to "685148e915e8b86aae68e558",
+ "Award Winning" to "685148fe15e8b86aae68e5a7",
+ "Doujinshi" to "6851490e15e8b86aae68e5da",
+ "Fan Colored" to "6851498215e8b86aae68e704",
+ "Full Color" to "685148d615e8b86aae68e502",
+ "Long Strip" to "685148d915e8b86aae68e517",
+ "Official Colored" to "6851493515e8b86aae68e64a",
+ "Oneshot" to "685148eb15e8b86aae68e56c",
+ "Self-Published" to "6851492e15e8b86aae68e633",
+ "Web Comic" to "685148d715e8b86aae68e50d",
+ ),
+)
+
+class GenreFilter : TriStateGroupFilter(
+ "Genre",
+ options = listOf(
+ "Action" to "685146c5f3ed681c80f257e3",
+ "Adult" to "689371f0a943baf927094f03",
+ "Adventure" to "685146c5f3ed681c80f257e6",
+ "Boys' Love" to "685148ef15e8b86aae68e573",
+ "Comedy" to "685146c5f3ed681c80f257e5",
+ "Crime" to "685148da15e8b86aae68e51f",
+ "Drama" to "685148cf15e8b86aae68e4dd",
+ "Ecchi" to "6892a73ba943baf927094e37",
+ "Fantasy" to "685146c5f3ed681c80f257ea",
+ "Girls' Love" to "685148da15e8b86aae68e524",
+ "Historical" to "685148db15e8b86aae68e527",
+ "Horror" to "685148da15e8b86aae68e520",
+ "Isekai" to "685146c5f3ed681c80f257e9",
+ "Magical Girls" to "6851490d15e8b86aae68e5d4",
+ "Mature" to "68932d11a943baf927094e7b",
+ "Mecha" to "6851490c15e8b86aae68e5d2",
+ "Medical" to "6851494e15e8b86aae68e66e",
+ "Mystery" to "685148d215e8b86aae68e4f4",
+ "Philosophical" to "685148e215e8b86aae68e544",
+ "Psychological" to "685148d715e8b86aae68e507",
+ "Romance" to "685148cf15e8b86aae68e4db",
+ "Sci-Fi" to "685148cf15e8b86aae68e4da",
+ "Shounen Ai" to "689f0ab1f2e66744c6091524",
+ "Slice of Life" to "685148d015e8b86aae68e4e3",
+ "Smut" to "689371f2a943baf927094f04",
+ "Sports" to "685148f515e8b86aae68e588",
+ "Superhero" to "6851492915e8b86aae68e61c",
+ "Thriller" to "685148d915e8b86aae68e51e",
+ "Tragedy" to "685148db15e8b86aae68e529",
+ "User Created" to "68932c3ea943baf927094e77",
+ "Wuxia" to "6851490715e8b86aae68e5c3",
+ "Yaoi" to "68932f68a943baf927094eaa",
+ "Yuri" to "6896a885a943baf927094f66",
+ ),
+)
+
+class OriginFilter : TriStateGroupFilter(
+ "Origin",
+ options = listOf(
+ "Comic" to "68ecab8507ec62d87e62780f",
+ "Manga" to "68ecab1e07ec62d87e627806",
+ "Manhua" to "68ecab4807ec62d87e62780b",
+ "Manhwa" to "68ecab3b07ec62d87e627809",
+ ),
+)
+
+class ThemeFilter : TriStateGroupFilter(
+ "Theme",
+ options = listOf(
+ "Aliens" to "6851490d15e8b86aae68e5d5",
+ "Animals" to "685148e715e8b86aae68e54b",
+ "Comics" to "68bf09ff8fdeab0b6a9bc2b7",
+ "Cooking" to "685148d215e8b86aae68e4f8",
+ "Crossdressing" to "685148df15e8b86aae68e534",
+ "Delinquents" to "685148d915e8b86aae68e519",
+ "Demons" to "685146c5f3ed681c80f257e4",
+ "Genderswap" to "685148d715e8b86aae68e505",
+ "Ghosts" to "685148d615e8b86aae68e501",
+ "Gyaru" to "685148d015e8b86aae68e4e8",
+ "Harem" to "685146c5f3ed681c80f257e8",
+ "Hentai" to "68bfceaf4dbc442a26519889",
+ "Incest" to "685148f215e8b86aae68e584",
+ "Loli" to "685148d715e8b86aae68e506",
+ "Mafia" to "685148d915e8b86aae68e518",
+ "Magic" to "685148d715e8b86aae68e509",
+ "Manhwa 18+" to "68f5f5ce5f29d3c1863dec3a",
+ "Martial Arts" to "6851490615e8b86aae68e5c2",
+ "Military" to "685148e215e8b86aae68e541",
+ "Monster Girls" to "685148db15e8b86aae68e52c",
+ "Monsters" to "685146c5f3ed681c80f257e2",
+ "Music" to "685148d015e8b86aae68e4e4",
+ "Ninja" to "685148d715e8b86aae68e508",
+ "Office Workers" to "685148d315e8b86aae68e4fd",
+ "Police" to "6851498815e8b86aae68e714",
+ "Post-Apocalyptic" to "685148e215e8b86aae68e540",
+ "Reincarnation" to "685146c5f3ed681c80f257e1",
+ "Reverse Harem" to "685148df15e8b86aae68e533",
+ "Samurai" to "6851490415e8b86aae68e5b9",
+ "School Life" to "685148d015e8b86aae68e4e7",
+ "Shota" to "685148d115e8b86aae68e4ed",
+ "Supernatural" to "685148db15e8b86aae68e528",
+ "Survival" to "685148cf15e8b86aae68e4dc",
+ "Time Travel" to "6851490c15e8b86aae68e5d1",
+ "Traditional Games" to "6851493515e8b86aae68e645",
+ "Vampires" to "685148f915e8b86aae68e597",
+ "Video Games" to "685148e115e8b86aae68e53c",
+ "Villainess" to "6851492115e8b86aae68e602",
+ "Virtual Reality" to "68514a1115e8b86aae68e83e",
+ "Zombies" to "6851490c15e8b86aae68e5d3",
+ ),
+)
+
+class TagIncludeMode : SelectFilter(
+ "Tag Include Mode",
+ options = listOf(
+ "AND" to "and",
+ "OR" to "or",
+ ),
+)
+
+class TagExcludeMode : SelectFilter(
+ "Tag Exclude Mode",
+ options = listOf(
+ "AND" to "and",
+ "OR" to "or",
+ ),
+)
+
+class DemographicFilter : SelectFilter(
+ "Magazine Demographic",
+ options = listOf(
+ "Any" to "any",
+ "Shounen" to "shounen",
+ "Shoujo" to "shoujo",
+ "Seinen" to "seinen",
+ "Josei" to "josei",
+ "Yuri" to "yuri",
+ "Yaoi" to "yaoi",
+ ),
+)
+
+class StatusFilter : SelectFilter(
+ "Publication Status",
+ options = listOf(
+ "Any" to "any",
+ "Ongoing" to "ongoing",
+ "Completed" to "completed",
+ "Hiatus" to "hiatus",
+ "Cancelled" to "cancelled",
+ ),
+)
diff --git a/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/MangaBall.kt b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/MangaBall.kt
new file mode 100644
index 000000000..e4bdc0956
--- /dev/null
+++ b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/MangaBall.kt
@@ -0,0 +1,403 @@
+package eu.kanade.tachiyomi.extension.all.mangaball
+
+import android.util.Log
+import androidx.preference.PreferenceScreen
+import androidx.preference.SwitchPreferenceCompat
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.POST
+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 eu.kanade.tachiyomi.util.asJsoup
+import keiyoushi.utils.firstInstance
+import keiyoushi.utils.getPreferencesLazy
+import keiyoushi.utils.parseAs
+import keiyoushi.utils.tryParse
+import okhttp3.Call
+import okhttp3.Callback
+import okhttp3.FormBody
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Request
+import okhttp3.Response
+import okhttp3.internal.closeQuietly
+import okio.IOException
+import org.jsoup.nodes.Document
+import rx.Observable
+import java.lang.UnsupportedOperationException
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class MangaBall(
+ override val lang: String,
+ private vararg val siteLang: String,
+) : HttpSource(), ConfigurableSource {
+
+ override val name = "Manga Ball"
+ override val baseUrl = "https://mangaball.net"
+ override val supportsLatest = true
+ private val preferences by getPreferencesLazy()
+
+ override val client = network.cloudflareClient.newBuilder()
+ .addInterceptor { chain ->
+ var request = chain.request()
+ if (request.url.pathSegments[0] == "api") {
+ request = request.newBuilder()
+ .header("X-Requested-With", "XMLHttpRequest")
+ .header("X-CSRF-TOKEN", getCSRF())
+ .build()
+
+ val response = chain.proceed(request)
+ if (!response.isSuccessful && response.code == 403) {
+ response.close()
+ request = request.newBuilder()
+ .header("X-CSRF-TOKEN", getCSRF(forceReset = true))
+ .build()
+
+ chain.proceed(request)
+ } else {
+ response
+ }
+ } else {
+ chain.proceed(request)
+ }
+ }
+ .build()
+
+ private var _csrf: String? = null
+
+ @Synchronized
+ private fun getCSRF(document: Document? = null, forceReset: Boolean = false): String {
+ if (_csrf == null || document != null || forceReset) {
+ val doc = document ?: client.newCall(
+ GET(baseUrl, headers),
+ ).execute().asJsoup()
+
+ doc.selectFirst("meta[name=csrf-token]")
+ ?.attr("content")
+ ?.takeIf { it.isNotBlank() }
+ ?.also { _csrf = it }
+ }
+
+ return _csrf ?: throw Exception("CSRF token not found")
+ }
+
+ override fun headersBuilder() = super.headersBuilder()
+ .set("Referer", "$baseUrl/")
+
+ override fun popularMangaRequest(page: Int): Request {
+ val filters = getFilterList().apply {
+ firstInstance().state = 6
+ }
+
+ return searchMangaRequest(page, "", filters)
+ }
+
+ override fun popularMangaParse(response: Response) =
+ searchMangaParse(response)
+
+ override fun latestUpdatesRequest(page: Int) =
+ searchMangaRequest(page, "", getFilterList())
+
+ override fun latestUpdatesParse(response: Response) =
+ searchMangaParse(response)
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable {
+ return if (query.startsWith("https://")) {
+ deepLink(query)
+ } else {
+ super.fetchSearchManga(page, query, filters)
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val body = FormBody.Builder().apply {
+ add("search_input", query.trim())
+ add("filters[sort]", filters.firstInstance().selected)
+ add("filters[page]", page.toString())
+ filters.filterIsInstance>().forEach { tags ->
+ tags.included.forEach { tag ->
+ add("filters[tag_included_ids][]", tag)
+ }
+ }
+ add("filters[tag_included_mode]", filters.firstInstance().selected)
+ filters.filterIsInstance>().forEach { tags ->
+ tags.excluded.forEach { tag ->
+ add("filters[tag_excluded_ids][]", tag)
+ }
+ }
+ add("filters[tag_excluded_mode]", filters.firstInstance().selected)
+ add("filters[contentRating]", "any")
+ add("filters[demographic]", filters.firstInstance().selected)
+ add("filters[person]", "any")
+ add("filters[publicationYear]", "")
+ add("filters[publicationStatus]", filters.firstInstance().selected)
+ siteLang.forEach {
+ add("filters[translatedLanguage][]", it)
+ }
+ }.build()
+
+ return POST("$baseUrl/api/v1/title/search-advanced/", headers, body)
+ }
+
+ override fun getFilterList() = FilterList(
+ SortFilter(),
+ DemographicFilter(),
+ StatusFilter(),
+ ContentFilter(),
+ FormatFilter(),
+ GenreFilter(),
+ OriginFilter(),
+ ThemeFilter(),
+ TagIncludeMode(),
+ TagExcludeMode(),
+ )
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val data = response.parseAs()
+ val hideNsfw = hideNsfwPreference()
+
+ val mangas = data.data
+ .filterNot {
+ it.isAdult && hideNsfw
+ }
+ .map {
+ SManga.create().apply {
+ url = it.url.toHttpUrl().pathSegments[1]
+ title = it.name
+ thumbnail_url = it.cover
+ }
+ }
+
+ if (mangas.isEmpty() && hideNsfw) {
+ throw Exception("All results filtered out due to nsfw filter")
+ }
+
+ return MangasPage(mangas, data.hasNextPage())
+ }
+
+ private fun deepLink(url: String): Observable {
+ val httpUrl = url.toHttpUrl()
+ if (
+ httpUrl.host == baseUrl.toHttpUrl().host &&
+ httpUrl.pathSegments.size >= 2 &&
+ httpUrl.pathSegments[0] in listOf("title-detail", "chapter-detail")
+ ) {
+ val slug = if (httpUrl.pathSegments[0] == "title-detail") {
+ httpUrl.pathSegments[1]
+ } else {
+ client.newCall(GET(httpUrl, headers)).execute()
+ .use { response ->
+ response.asJsoup()
+ .selectFirst(".yoast-schema-graph")!!.data()
+ .parseAs()
+ .graph.first { it.type == "WebPage" }
+ .url!!.toHttpUrl()
+ .pathSegments[1]
+ }
+ }
+
+ val manga = SManga.create().apply {
+ this.url = slug
+ }
+
+ return fetchMangaDetails(manga).map {
+ MangasPage(listOf(it), false)
+ }
+ }
+
+ throw Exception("Unsupported url")
+ }
+
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ return GET(getMangaUrl(manga), headers)
+ }
+
+ override fun getMangaUrl(manga: SManga): String {
+ return "$baseUrl/title-detail/${manga.url}/"
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val document = response.asJsoup()
+ getCSRF(document)
+
+ return SManga.create().apply {
+ url = document.location().toHttpUrl().pathSegments[1]
+ title = document.selectFirst("#comicDetail h6")!!.ownText()
+ thumbnail_url = document.selectFirst("img.featured-cover")?.absUrl("src")
+ genre = buildList {
+ document.selectFirst("#featuredComicsCarousel img[src*=/flags/]")
+ ?.attr("src")?.also {
+ when {
+ it.contains("jp") -> add("Manga")
+ it.contains("kr") -> add("Manhwa")
+ it.contains("cn") -> add("Manhua")
+ }
+ }
+ document.select("#comicDetail span[data-tag-id]")
+ .mapTo(this) { it.ownText() }
+ }.joinToString()
+ author = document.select("#comicDetail span[data-person-id]")
+ .eachText().joinToString()
+ description = buildString {
+ document.selectFirst("#descriptionContent p")
+ ?.also { append(it.wholeText()) }
+ document.selectFirst("#comicDetail span.badge:contains(Published)")
+ ?.also { append("\n\n", it.text()) }
+ val titles = document.select("div.alternate-name-container").text().split("/")
+ if (titles.isNotEmpty()) {
+ append("\n\nAlternative Names: \n")
+ titles.forEach {
+ append("- ", it.trim(), "\n")
+ }
+ }
+ }.trim()
+ status = when (document.selectFirst("span.badge-status")?.text()) {
+ "Ongoing" -> SManga.ONGOING
+ "Completed" -> SManga.COMPLETED
+ "Hiatus" -> SManga.ON_HIATUS
+ "Cancelled" -> SManga.CANCELLED
+ else -> SManga.UNKNOWN
+ }
+ }
+ }
+
+ override fun chapterListRequest(manga: SManga): Request {
+ val id = manga.url.substringAfterLast("-")
+ val body = FormBody.Builder()
+ .add("title_id", id)
+ .build()
+
+ return POST("$baseUrl/api/v1/chapter/chapter-listing-by-title-id/", headers, body)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ (response.request.body as FormBody).also {
+ updateViews(it.value(0))
+ }
+
+ val data = response.parseAs()
+
+ return data.chapters.flatMap { chapter ->
+ chapter.translations.mapNotNull { translation ->
+ if (translation.language in siteLang) {
+ SChapter.create().apply {
+ url = translation.id
+ name = buildString {
+ if (translation.volume > 0) {
+ append("Vol. ")
+ append(translation.volume)
+ append(" ")
+ }
+ val number = chapter.number.toString().removeSuffix(".0")
+ if (translation.name.contains(number)) {
+ append(translation.name.trim())
+ } else {
+ append("Ch. ")
+ append(number)
+ append(" ")
+ append(translation.name.trim())
+ }
+ }
+ chapter_number = chapter.number
+ date_upload = dateFormat.tryParse(translation.date)
+ scanlator = buildString {
+ append(translation.group.name)
+ // id is usually the name of the site the chapter was scraped from
+ // if not then it is generated id of an active group on the site
+ if (groupIdRegex.matchEntire(translation.group.id) == null) {
+ append(" (")
+ append(translation.group.id)
+ append(")")
+ }
+ }
+ }
+ } else {
+ null
+ }
+ }
+ }
+ }
+
+ private val groupIdRegex = Regex("""[a-z0-9]{24}""")
+
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ROOT)
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ return GET(getChapterUrl(chapter), headers)
+ }
+
+ override fun getChapterUrl(chapter: SChapter): String {
+ return "$baseUrl/chapter-detail/${chapter.url}/"
+ }
+
+ override fun pageListParse(response: Response): List {
+ val document = response.asJsoup()
+ getCSRF(document)
+
+ document.select("script:containsData(titleId)").joinToString(";") { it.data() }.also {
+ val titleId = titleIdRegex.find(it)
+ ?.groupValues?.get(1)
+ ?: return@also
+ val chapterId = chapterIdRegex.find(it)
+ ?.groupValues?.get(1)
+ ?: return@also
+
+ updateViews(titleId, chapterId)
+ }
+
+ val script = document.select("script:containsData(chapterImages)").joinToString(";") { it.data() }
+ val images = imagesRegex.find(script)
+ ?.groupValues?.get(1)
+ ?.parseAs>()
+ .orEmpty()
+
+ return images.mapIndexed { idx, img ->
+ Page(idx, imageUrl = img)
+ }
+ }
+
+ private val imagesRegex = Regex("""const\s+chapterImages\s*=\s*JSON\.parse\(`([^`]+)`\)""")
+ private val titleIdRegex = Regex("""const\s+titleId\s*=\s*`([^`]+)`;""")
+ private val chapterIdRegex = Regex("""const\s+chapterId\s*=\s*`([^`]+)`;""")
+
+ private fun updateViews(titleId: String, chapterId: String = "") {
+ val body = FormBody.Builder()
+ .add("title_id", titleId)
+ .add("chapter_id", chapterId)
+ .build()
+
+ val request = POST("$baseUrl/api/v1/views/update/", headers, body)
+
+ client.newCall(request)
+ .enqueue(
+ object : Callback {
+ override fun onResponse(call: Call, response: Response) {
+ response.closeQuietly()
+ }
+ override fun onFailure(call: Call, e: IOException) {
+ Log.e(name, "Failed to update views", e)
+ }
+ },
+ )
+ }
+
+ override fun setupPreferenceScreen(screen: PreferenceScreen) {
+ SwitchPreferenceCompat(screen.context).apply {
+ key = NSFW_PREF
+ title = "Hide NSFW content"
+ setDefaultValue(false)
+ }.also(screen::addPreference)
+ }
+
+ private fun hideNsfwPreference() = preferences.getBoolean(NSFW_PREF, false)
+
+ override fun imageUrlParse(response: Response): String {
+ throw UnsupportedOperationException()
+ }
+}
+
+private const val NSFW_PREF = "nsfw_pref"
diff --git a/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/MangaBallFactory.kt b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/MangaBallFactory.kt
new file mode 100644
index 000000000..e1a4a4ed6
--- /dev/null
+++ b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/MangaBallFactory.kt
@@ -0,0 +1,50 @@
+package eu.kanade.tachiyomi.extension.all.mangaball
+
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class MangaBallFactory : SourceFactory {
+ override fun createSources() = listOf(
+ MangaBall("ar", "ar"),
+ MangaBall("bg", "bg"),
+ MangaBall("bn", "bn"),
+ MangaBall("ca", "ca", "ca-ad", "ca-es", "ca-fr", "ca-it", "ca-pt"),
+ MangaBall("cs", "cs"),
+ MangaBall("da", "da"),
+ MangaBall("de", "de"),
+ MangaBall("el", "el"),
+ MangaBall("en", "en"),
+ MangaBall("es", "es", "es-ar", "es-mx", "es-es", "es-la", "es-419"),
+ MangaBall("fa", "fa"),
+ MangaBall("fi", "fi"),
+ MangaBall("fr", "fr"),
+ MangaBall("he", "he"),
+ MangaBall("hi", "hi"),
+ MangaBall("hu", "hu"),
+ MangaBall("id", "id"),
+ MangaBall("it", "it", "it-it"),
+ MangaBall("is", "ib", "ib-is", "is"),
+ MangaBall("ja", "jp"),
+ MangaBall("ko", "kr"),
+ MangaBall("kn", "kn", "kn-in", "kn-my", "kn-sg", "kn-tw"),
+ MangaBall("ml", "ml", "ml-in", "ml-my", "ml-sg", "ml-tw"),
+ MangaBall("ms", "ms"),
+ MangaBall("ne", "ne"),
+ MangaBall("nl", "nl", "nl-be"),
+ MangaBall("no", "no"),
+ MangaBall("pl", "pl"),
+ MangaBall("pt-BR", "pt-br", "pt-pt"),
+ MangaBall("ro", "ro"),
+ MangaBall("ru", "ru"),
+ MangaBall("sk", "sk"),
+ MangaBall("sl", "sl"),
+ MangaBall("sq", "sq"),
+ MangaBall("sr", "sr", "sr-cyrl"),
+ MangaBall("sv", "sv"),
+ MangaBall("ta", "ta"),
+ MangaBall("th", "th", "th-hk", "th-kh", "th-la", "th-my", "th-sg"),
+ MangaBall("tr", "tr"),
+ MangaBall("uk", "uk"),
+ MangaBall("vi", "vi"),
+ MangaBall("zh", "zh", "zh-cn", "zh-hk", "zh-mo", "zh-sg", "zh-tw"),
+ )
+}
diff --git a/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/UrlActivity.kt b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/UrlActivity.kt
new file mode 100644
index 000000000..33e5b18e9
--- /dev/null
+++ b/src/all/mangaball/src/eu/kanade/tachiyomi/extension/all/mangaball/UrlActivity.kt
@@ -0,0 +1,29 @@
+package eu.kanade.tachiyomi.extension.all.mangaball
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class UrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", intent.data.toString())
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("MangaBall", "Unable to launch activity", e)
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}