diff --git a/src/en/batcave/build.gradle b/src/en/batcave/build.gradle new file mode 100644 index 0000000000..b51ef7e330 --- /dev/null +++ b/src/en/batcave/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'BatCave' + extClass = '.BatCave' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/batcave/res/mipmap-hdpi/ic_launcher.png b/src/en/batcave/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..a89dbaaddd Binary files /dev/null and b/src/en/batcave/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/batcave/res/mipmap-mdpi/ic_launcher.png b/src/en/batcave/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..532b1bdb72 Binary files /dev/null and b/src/en/batcave/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/batcave/res/mipmap-xhdpi/ic_launcher.png b/src/en/batcave/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..f2a7807273 Binary files /dev/null and b/src/en/batcave/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/batcave/res/mipmap-xxhdpi/ic_launcher.png b/src/en/batcave/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..dce7c261d9 Binary files /dev/null and b/src/en/batcave/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/batcave/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/batcave/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..1e60a7a548 Binary files /dev/null and b/src/en/batcave/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/BatCave.kt b/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/BatCave.kt new file mode 100644 index 0000000000..fd9f03bdf5 --- /dev/null +++ b/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/BatCave.kt @@ -0,0 +1,239 @@ +package eu.kanade.tachiyomi.extension.en.batcave + +import android.util.Log +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +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.SerializationException +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import uy.kohesive.injekt.injectLazy +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +class BatCave : HttpSource() { + + override val name = "BatCave" + override val lang = "en" + override val supportsLatest = true + override val baseUrl = "https://batcave.biz" + + private val json: Json by injectLazy() + + override fun popularMangaRequest(page: Int) = searchMangaRequest(page, "", SortFilter.POPULAR) + override fun popularMangaParse(response: Response) = searchMangaParse(response) + + override fun latestUpdatesRequest(page: Int) = searchMangaRequest(page, "", SortFilter.LATEST) + override fun latestUpdatesParse(response: Response) = searchMangaParse(response) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.isNotBlank()) { + val url = "$baseUrl/search/".toHttpUrl().newBuilder().apply { + addPathSegment(query.trim()) + if (page > 1) { + addPathSegments("page/$page/") + } + }.build() + + return GET(url, headers) + } + + var filtersApplied = false + + val url = "$baseUrl/comix/".toHttpUrl().newBuilder().apply { + filters.get()?.addFilterToUrl(this) + ?.also { filtersApplied = it } + filters.get()?.addFilterToUrl(this) + ?.also { filtersApplied = filtersApplied || it } + filters.get()?.addFilterToUrl(this) + ?.also { filtersApplied = filtersApplied || it } + + if (filtersApplied) { + setPathSegment(0, "ComicList") + } + if (page > 1) { + addPathSegments("page/$page/") + } + }.build().toString() + + val sort = filters.get()!! + + return if (sort.getSort() == "") { + GET(url, headers) + } else { + val form = FormBody.Builder().apply { + add("dlenewssortby", sort.getSort()) + add("dledirection", sort.getDirection()) + if (filtersApplied) { + add("set_new_sort", "dle_sort_xfilter") + add("set_direction_sort", "dle_direction_xfilter") + } else { + add("set_new_sort", "dle_sort_cat_1") + add("set_direction_sort", "dle_direction_cat_1") + } + }.build() + + POST(url, headers, form) + } + } + + private var publishers: List> = emptyList() + private var genres: List> = emptyList() + private var filterParseFailed = false + + override fun getFilterList(): FilterList { + val filters: MutableList> = mutableListOf( + Filter.Header("Doesn't work with text search"), + SortFilter(), + YearFilter(), + ) + if (publishers.isNotEmpty()) { + filters.add( + PublisherFilter(publishers), + ) + } + if (genres.isNotEmpty()) { + filters.add( + GenreFilter(genres), + ) + } + if (filters.size < 5) { + filters.add( + Filter.Header( + if (filterParseFailed) { + "Unable to load more filters" + } else { + "Press 'reset' to load more filters" + }, + ), + ) + } + + return FilterList(filters) + } + + private fun parseFilters(documented: Document) { + val script = documented.selectFirst("script:containsData(__XFILTER__)") + + if (script == null) { + filterParseFailed = true + return + } + + val data = try { + script.data() + .substringAfter("=") + .trim() + .removeSuffix(";") + .parseAs() + } catch (e: SerializationException) { + Log.e(name, "filters", e) + filterParseFailed = true + return + } + + publishers = data.filterItems.publisher.values.map { it.value to it.id } + genres = data.filterItems.genre.values.map { it.value to it.id } + filterParseFailed = false + + return + } + + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + if (response.request.url.pathSegments[0] != "search") { + parseFilters(document) + } + val entries = document.select("#dle-content > .readed").map { element -> + SManga.create().apply { + with(element.selectFirst(".readed__title > a")!!) { + setUrlWithoutDomain(absUrl("href")) + title = ownText() + } + thumbnail_url = element.selectFirst("img")?.absUrl("data-src") + } + } + val hasNextPage = document.selectFirst("div.pagination__pages") + ?.children()?.last()?.tagName() == "a" + + return MangasPage(entries, hasNextPage) + } + + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + + return SManga.create().apply { + title = document.selectFirst("header.page__header h1")!!.text() + thumbnail_url = document.selectFirst("div.page__poster img")?.absUrl("src") + description = document.selectFirst("div.page__text")?.wholeText() + author = document.selectFirst(".page__list > li:has(> div:contains(Publisher))")?.ownText() + status = when (document.selectFirst(".page__list > li:has(> div:contains(release type))")?.ownText()?.trim()) { + "Ongoing" -> SManga.ONGOING + "Complete" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + val data = document.selectFirst(".page__chapters-list script:containsData(__DATA__)")!!.data() + .substringAfter("=") + .trim() + .removeSuffix(";") + .parseAs() + + return data.chapters.map { chap -> + SChapter.create().apply { + url = "/reader/${data.comicId}/${chap.id}${data.xhash}" + name = chap.title + chapter_number = chap.number + date_upload = try { + dateFormat.parse(chap.date)?.time ?: 0 + } catch (_: ParseException) { + 0 + } + } + } + } + + private val dateFormat = SimpleDateFormat("dd.MM.yyyy", Locale.US) + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + val data = document.selectFirst("script:containsData(__DATA__)")!!.data() + .substringAfter("=") + .trim() + .removeSuffix(";") + .parseAs() + + return data.images.mapIndexed { idx, img -> + Page(idx, imageUrl = baseUrl + img.trim()) + } + } + + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException() + } + + private inline fun FilterList.get(): T? { + return filterIsInstance().firstOrNull() + } + + private inline fun String.parseAs(): T { + return json.decodeFromString(this) + } +} diff --git a/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/Dto.kt b/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/Dto.kt new file mode 100644 index 0000000000..22eb167786 --- /dev/null +++ b/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/Dto.kt @@ -0,0 +1,47 @@ +package eu.kanade.tachiyomi.extension.en.batcave + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class XFilters( + @SerialName("filter_items") val filterItems: XFilterItems = XFilterItems(), +) + +@Serializable +class XFilterItems( + @SerialName("p") val publisher: XFilterItem = XFilterItem(), + @SerialName("g") var genre: XFilterItem = XFilterItem(), + +) + +@Serializable +class XFilterItem( + val values: ArrayList = arrayListOf(), +) + +@Serializable +class Values( + val id: Int, + val value: String, +) + +@Serializable +class Chapters( + @SerialName("news_id") val comicId: Int, + val chapters: List, + val xhash: String, +) + +@Serializable +class Chapter( + val id: Int, + @SerialName("posi") val number: Float, + val title: String, + val date: String, +) + +@Serializable +class Images( + val images: List, +) diff --git a/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/Filters.kt b/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/Filters.kt new file mode 100644 index 0000000000..0d5efbcda5 --- /dev/null +++ b/src/en/batcave/src/eu/kanade/tachiyomi/extension/en/batcave/Filters.kt @@ -0,0 +1,113 @@ +package eu.kanade.tachiyomi.extension.en.batcave + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.HttpUrl +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +interface UrlPartFilter { + fun addFilterToUrl(url: HttpUrl.Builder): Boolean +} + +class CheckBoxItem(name: String, val value: Int) : Filter.CheckBox(name) + +open class CheckBoxFilter( + name: String, + private val queryParameter: String, + values: List>, +) : Filter.Group( + name, + values.map { CheckBoxItem(it.first, it.second) }, +), + UrlPartFilter { + override fun addFilterToUrl(url: HttpUrl.Builder): Boolean { + val checked = state.filter { it.state } + .also { if (it.isEmpty()) return false } + .joinToString(",") { it.value.toString() } + + url.addPathSegments("$queryParameter=$checked/") + return true + } +} + +class PublisherFilter(values: List>) : + CheckBoxFilter("Publisher", "p", values) + +class GenreFilter(values: List>) : + CheckBoxFilter("Genre", "g", values) + +class TextBox(name: String) : Filter.Text(name) + +class YearFilter : + Filter.Group( + "Year of Issue", + listOf( + TextBox("from"), + TextBox("to"), + ), + ), + UrlPartFilter { + override fun addFilterToUrl(url: HttpUrl.Builder): Boolean { + var applied = false + val currentYear = yearFormat.format(Date()).toInt() + if (state[0].state.isNotBlank()) { + val from = try { + state[0].state.toInt() + } catch (_: NumberFormatException) { + throw Exception("year must be number") + } + assert(from in 1929..currentYear) { + "invalid start year (must be between 1929 and $currentYear)" + } + url.addPathSegments("y[from]=$from/") + applied = true + } + if (state[1].state.isNotBlank()) { + val to = try { + state[1].state.toInt() + } catch (_: NumberFormatException) { + throw Exception("year must be number") + } + assert(to in 1929..currentYear) { + "invalid start year (must be between 1929 and $currentYear)" + } + url.addPathSegments("y[to]=$to/") + applied = true + } + return applied + } +} + +private val yearFormat = SimpleDateFormat("yyyy", Locale.ENGLISH) + +class SortFilter( + select: Selection = Selection(0, false), +) : Filter.Sort( + "Sort", + sorts.map { it.first }.toTypedArray(), + select, +) { + fun getSort() = sorts[state?.index ?: 0].second + fun getDirection() = if (state?.ascending != false) { + "asc" + } else { + "desc" + } + + companion object { + val POPULAR = FilterList(SortFilter(Selection(3, false))) + val LATEST = FilterList(SortFilter(Selection(2, false))) + } +} + +private val sorts = listOf( + "Default" to "", + "Date" to "date", + "Date of change" to "editdate", + "Rating" to "rating", + "Read" to "news_read", + "Comments" to "comm_num", + "Title" to "title", +)