diff --git a/src/ru/libhentai/AndroidManifest.xml b/src/ru/libhentai/AndroidManifest.xml new file mode 100644 index 000000000..c33cfb934 --- /dev/null +++ b/src/ru/libhentai/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + diff --git a/src/ru/libhentai/build.gradle b/src/ru/libhentai/build.gradle new file mode 100644 index 000000000..78fa3a4e6 --- /dev/null +++ b/src/ru/libhentai/build.gradle @@ -0,0 +1,17 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'HentaiLib' + pkgNameSuffix = 'ru.libhentai' + extClass = '.LibHentai' + extVersionCode = 1 + isNsfw = true +} + +dependencies { + implementation project(path: ':lib-ratelimit') +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ru/libhentai/res/mipmap-hdpi/ic_launcher.png b/src/ru/libhentai/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..41cb9dd7a Binary files /dev/null and b/src/ru/libhentai/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/ru/libhentai/res/mipmap-mdpi/ic_launcher.png b/src/ru/libhentai/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..40d1a9c78 Binary files /dev/null and b/src/ru/libhentai/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/ru/libhentai/res/mipmap-xhdpi/ic_launcher.png b/src/ru/libhentai/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..ca16a46fd Binary files /dev/null and b/src/ru/libhentai/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/ru/libhentai/res/mipmap-xxhdpi/ic_launcher.png b/src/ru/libhentai/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..01ba951d0 Binary files /dev/null and b/src/ru/libhentai/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/ru/libhentai/res/mipmap-xxxhdpi/ic_launcher.png b/src/ru/libhentai/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..8171958cd Binary files /dev/null and b/src/ru/libhentai/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/ru/libhentai/res/web_hi_res_512.png b/src/ru/libhentai/res/web_hi_res_512.png new file mode 100644 index 000000000..ace364704 Binary files /dev/null and b/src/ru/libhentai/res/web_hi_res_512.png differ diff --git a/src/ru/libhentai/src/eu/kanade/tachiyomi/extension/ru/libhentai/LibHentai.kt b/src/ru/libhentai/src/eu/kanade/tachiyomi/extension/ru/libhentai/LibHentai.kt new file mode 100644 index 000000000..5c9ef8e0b --- /dev/null +++ b/src/ru/libhentai/src/eu/kanade/tachiyomi/extension/ru/libhentai/LibHentai.kt @@ -0,0 +1,889 @@ +package eu.kanade.tachiyomi.extension.ru.libhentai + +import android.app.Application +import android.content.SharedPreferences +import android.widget.Toast +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.lib.ratelimit.RateLimitInterceptor +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +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.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Element +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.concurrent.TimeUnit + +class LibHentai : ConfigurableSource, HttpSource() { + + private val json: Json by injectLazy() + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_${id}_2", 0x0000) + } + + override val name: String = "Hentailib" + + override val lang = "ru" + + override val supportsLatest = true + + override val client: OkHttpClient = network.cloudflareClient.newBuilder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addNetworkInterceptor(RateLimitInterceptor(3)) + .build() + + override val baseUrl = "https://hentailib.me" + + override fun headersBuilder() = Headers.Builder().apply { + add("User-Agent", "Mozilla/5.0 (Windows NT 6.3; WOW64)") + add("Accept", "image/webp,*/*;q=0.8") + add("Referer", baseUrl) + } + + override fun latestUpdatesRequest(page: Int) = GET(baseUrl, headers) + + private val latestUpdatesSelector = "div.updates__item" + + override fun latestUpdatesParse(response: Response): MangasPage { + val elements = response.asJsoup().select(latestUpdatesSelector) + val latestMangas = elements?.map { latestUpdatesFromElement(it) } + if (latestMangas != null) + return MangasPage(latestMangas, false) // TODO: use API + return MangasPage(emptyList(), false) + } + + private fun latestUpdatesFromElement(element: Element): SManga { + val manga = SManga.create() + element.select("div.cover").first().let { img -> + manga.thumbnail_url = img.attr("data-src").replace("_thumb", "_250x350") + } + + element.select("a").first().let { link -> + manga.setUrlWithoutDomain(link.attr("href")) + manga.title = if (titleLanguage.equals("rus") || element.select(".updates__name_rus").isNullOrEmpty()) { element.select("h4").first().text() } else element.select(".updates__name_rus").first().text() + } + return manga + } + + private var csrfToken: String = "" + + private fun catalogHeaders() = Headers.Builder() + .apply { + add("Accept", "application/json, text/plain, */*") + add("X-Requested-With", "XMLHttpRequest") + add("x-csrf-token", csrfToken) + } + .build() + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/login", headers) + + override fun fetchPopularManga(page: Int): Observable { + if (csrfToken.isEmpty()) { + return client.newCall(popularMangaRequest(page)) + .asObservableSuccess() + .flatMap { response -> + // Obtain token + val resBody = response.body!!.string() + csrfToken = "_token\" content=\"(.*)\"".toRegex().find(resBody)!!.groups[1]!!.value + return@flatMap fetchPopularMangaFromApi(page) + } + } + return fetchPopularMangaFromApi(page) + } + + private fun fetchPopularMangaFromApi(page: Int): Observable { + return client.newCall(POST("$baseUrl/filterlist?dir=desc&sort=views&page=$page", catalogHeaders())) + .asObservableSuccess() + .map { response -> + popularMangaParse(response) + } + } + + override fun popularMangaParse(response: Response): MangasPage { + val resBody = response.body!!.string() + val result = json.decodeFromString(resBody) + val items = result["items"]!!.jsonObject + val popularMangas = items["data"]?.jsonArray?.map { popularMangaFromElement(it) } + + if (popularMangas != null) { + val hasNextPage = items["next_page_url"]?.jsonPrimitive?.contentOrNull != null + return MangasPage(popularMangas, hasNextPage) + } + return MangasPage(emptyList(), false) + } + + private fun popularMangaFromElement(el: JsonElement) = SManga.create().apply { + val slug = el.jsonObject["slug"]!!.jsonPrimitive.content + val cover = el.jsonObject["cover"]!!.jsonPrimitive.content + title = if (titleLanguage.equals("rus")) el.jsonObject["rus_name"]!!.jsonPrimitive.content else el.jsonObject["name"]!!.jsonPrimitive.content + thumbnail_url = "$COVER_URL/huploads/cover/$slug/cover/${cover}_250x350.jpg" + url = "/$slug" + } + + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + + if (document.select("body[data-page=home]").isNotEmpty()) + throw Exception("Can't open manga. Try log in via WebView") + + val manga = SManga.create() + + val body = document.select("div.media-info-list").first() + val rawCategory = body.select("div.media-info-list__title:contains(Тип) + div").text() + val category = when { + rawCategory == "Комикс западный" -> "Комикс" + rawCategory.isNotBlank() -> rawCategory + else -> "Манга" + } + var rawAgeStop = body.select("div.media-info-list__title:contains(Возрастной рейтинг) + div").text() + if (rawAgeStop.isEmpty()) { + rawAgeStop = "0+" + } + + val ratingValue = document.select(".media-rating.media-rating_lg div.media-rating__value").text().toFloat() * 2 + val ratingVotes = document.select(".media-rating.media-rating_lg div.media-rating__votes").text() + val ratingStar = when { + ratingValue > 9.5 -> "★★★★★" + ratingValue > 8.5 -> "★★★★✬" + ratingValue > 7.5 -> "★★★★☆" + ratingValue > 6.5 -> "★★★✬☆" + ratingValue > 5.5 -> "★★★☆☆" + ratingValue > 4.5 -> "★★✬☆☆" + ratingValue > 3.5 -> "★★☆☆☆" + ratingValue > 2.5 -> "★✬☆☆☆" + ratingValue > 1.5 -> "★☆☆☆☆" + ratingValue > 0.5 -> "✬☆☆☆☆" + else -> "☆☆☆☆☆" + } + val genres = document.select(".media-tags > a").map { it.text().capitalize() } + manga.title = if (titleLanguage.equals("rus")) document.select(".media-name__main").text() else document.select(".media-name__alt").text() + manga.thumbnail_url = document.select(".media-sidebar__cover > img").attr("src") + manga.author = body.select("div.media-info-list__title:contains(Автор) + div").text() + manga.artist = body.select("div.media-info-list__title:contains(Художник) + div").text() + manga.status = if (document.html().contains("Манга удалена по просьбе правообладателей") || + document.html().contains("Данный тайтл лицензирован на территории РФ.") + ) { + SManga.LICENSED + } else + when ( + body.select("div.media-info-list__title:contains(Статус перевода) + div") + .text() + .toLowerCase(Locale.ROOT) + ) { + "продолжается" -> SManga.ONGOING + "завершен" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + manga.genre = genres.plusElement(category).plusElement(rawAgeStop).joinToString { it.trim() } + val altSelector = document.select(".media-info-list__item_alt-names .media-info-list__value div") + var altName = "" + if (altSelector.isNotEmpty()) { + altName = "Альтернативные названия:\n" + altSelector.map { it.text() }.joinToString(" / ") + "\n\n" + } + val mediaNameLanguage = if (titleLanguage.equals("rus")) document.select(".media-name__alt").text() else document.select(".media-name__main").text() + manga.description = mediaNameLanguage + "\n" + ratingStar + " " + ratingValue + " (голосов: " + ratingVotes + ")\n" + altName + document.select(".media-description__text").text() + return manga + } + + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + if (document.html().contains("Манга удалена по просьбе правообладателей") || + document.html().contains("Данный тайтл лицензирован на территории РФ.") + ) { + return emptyList() + } + val dataStr = document + .toString() + .substringAfter("window.__DATA__ = ") + .substringBefore("window._SITE_COLOR_") + .substringBeforeLast(";") + + val data = json.decodeFromString(dataStr) + val chaptersList = data["chapters"]!!.jsonObject["list"]?.jsonArray + val slug = data["manga"]!!.jsonObject["slug"]!!.jsonPrimitive.content + val branches = data["chapters"]!!.jsonObject["branches"]!!.jsonArray.reversed() + val sortingList = preferences.getString(SORTING_PREF, "ms_mixing") + + val chapters: List? = if (branches.isNotEmpty() && !sortingList.equals("ms_mixing")) { + sortChaptersByTranslator(sortingList, chaptersList, slug, branches) + } else { + chaptersList + ?.filter { it.jsonObject["status"]?.jsonPrimitive?.intOrNull != 2 } + ?.map { chapterFromElement(it, sortingList, slug) } + } + + return chapters ?: emptyList() + } + + private fun sortChaptersByTranslator + (sortingList: String?, chaptersList: JsonArray?, slug: String, branches: List): List? { + var chapters: List? = null + when (sortingList) { + "ms_combining" -> { + val tempChaptersList = mutableListOf() + for (currentBranch in branches.withIndex()) { + val teamId = branches[currentBranch.index].jsonObject["id"]!!.jsonPrimitive.int + chapters = chaptersList + ?.filter { it.jsonObject["branch_id"]?.jsonPrimitive?.intOrNull == teamId && it.jsonObject["status"]?.jsonPrimitive?.intOrNull != 2 } + ?.map { chapterFromElement(it, sortingList, slug, teamId, branches) } + chapters?.let { tempChaptersList.addAll(it) } + } + chapters = tempChaptersList + } + "ms_largest" -> { + val sizesChaptersLists = mutableListOf() + for (currentBranch in branches.withIndex()) { + val teamId = branches[currentBranch.index].jsonObject["id"]!!.jsonPrimitive.int + val chapterSize = chaptersList + ?.filter { it.jsonObject["branch_id"]?.jsonPrimitive?.intOrNull == teamId }!!.size + sizesChaptersLists.add(chapterSize) + } + val max = sizesChaptersLists.indexOfFirst { it == sizesChaptersLists.maxOrNull() ?: 0 } + val teamId = branches[max].jsonObject["id"]!!.jsonPrimitive.int + + chapters = chaptersList + ?.filter { it.jsonObject["branch_id"]?.jsonPrimitive?.intOrNull == teamId && it.jsonObject["status"]?.jsonPrimitive?.intOrNull != 2 } + ?.map { chapterFromElement(it, sortingList, slug, teamId, branches) } + } + "ms_active" -> { + for (currentBranch in branches.withIndex()) { + val teams = branches[currentBranch.index].jsonObject["teams"]!!.jsonArray + for (currentTeam in teams.withIndex()) { + if (teams[currentTeam.index].jsonObject["is_active"]!!.jsonPrimitive.int == 1) { + val teamId = branches[currentBranch.index].jsonObject["id"]!!.jsonPrimitive.int + chapters = chaptersList + ?.filter { it.jsonObject["branch_id"]?.jsonPrimitive?.intOrNull == teamId && it.jsonObject["status"]?.jsonPrimitive?.intOrNull != 2 } + ?.map { chapterFromElement(it, sortingList, slug, teamId, branches) } + break + } + } + } + chapters ?: throw Exception("Активный перевод не назначен на сайте") + } + } + + return chapters + } + + private fun chapterFromElement + (chapterItem: JsonElement, sortingList: String?, slug: String, teamIdParam: Int? = null, branches: List? = null): SChapter { + val chapter = SChapter.create() + + val volume = chapterItem.jsonObject["chapter_volume"]!!.jsonPrimitive.int + val number = chapterItem.jsonObject["chapter_number"]!!.jsonPrimitive.content + val teamId = if (teamIdParam != null) "?bid=$teamIdParam" else "" + + val url = "$baseUrl/$slug/v$volume/c$number$teamId" + + chapter.setUrlWithoutDomain(url) + + val nameChapter = chapterItem.jsonObject["chapter_name"]?.jsonPrimitive?.contentOrNull + val fullNameChapter = "Том $volume. Глава $number" + + if (!sortingList.equals("ms_mixing")) { + chapter.scanlator = branches?.let { getScanlatorTeamName(it, chapterItem) } ?: chapterItem.jsonObject["username"]!!.jsonPrimitive.content + } + chapter.name = if (nameChapter.isNullOrBlank()) fullNameChapter else "$fullNameChapter - $nameChapter" + chapter.date_upload = SimpleDateFormat("yyyy-MM-dd", Locale.US) + .parse(chapterItem.jsonObject["chapter_created_at"]!!.jsonPrimitive.content.substringBefore(" "))?.time ?: 0L + + return chapter + } + + private fun getScanlatorTeamName(branches: List, chapterItem: JsonElement): String? { + var scanlatorData: String? = null + for (currentBranch in branches.withIndex()) { + val branch = branches[currentBranch.index].jsonObject + val teams = branch["teams"]!!.jsonArray + if (chapterItem.jsonObject["branch_id"]!!.jsonPrimitive.int == branch["id"]!!.jsonPrimitive.int) { + for (currentTeam in teams.withIndex()) { + val team = teams[currentTeam.index].jsonObject + val scanlatorId = chapterItem.jsonObject["chapter_scanlator_id"]!!.jsonPrimitive.int + scanlatorData = if ((scanlatorId == team.jsonObject["id"]!!.jsonPrimitive.int) || + (scanlatorId == 0 && team["is_active"]!!.jsonPrimitive.int == 1) + ) team["name"]!!.jsonPrimitive.content else branch["teams"]!!.jsonArray[0].jsonObject["name"]!!.jsonPrimitive.content + } + } + } + return scanlatorData + } + + override fun prepareNewChapter(chapter: SChapter, manga: SManga) { + """Глава\s(\d+)""".toRegex().find(chapter.name)?.let { + val number = it.groups[1]?.value!! + chapter.chapter_number = number.toFloat() + } + } + + override fun pageListParse(response: Response): List { + val document = response.asJsoup() + + val redirect = document.html() + if (!redirect.contains("window.__info")) { + if (redirect.contains("hold-transition login-page")) { + throw Exception("Для просмотра 18+ контента необходима авторизация через WebView") + } else if (redirect.contains("header__logo")) { + throw Exception("Лицензировано - Главы не доступны") + } + } + + val chapInfo = document + .select("script:containsData(window.__info)") + .first() + .html() + .split("window.__info = ") + .last() + .trim() + .split(";") + .first() + + val chapInfoJson = json.decodeFromString(chapInfo) + val servers = chapInfoJson["servers"]!!.jsonObject.toMap() + val defaultServer: String = chapInfoJson["img"]!!.jsonObject["server"]!!.jsonPrimitive.content + val autoServer = setOf("secondary", "fourth", defaultServer, "compress") + val imgUrl: String = chapInfoJson["img"]!!.jsonObject["url"]!!.jsonPrimitive.content + + val serverToUse = when (this.server) { + null -> autoServer + "auto" -> autoServer + else -> listOf(this.server) + } + + // Get pages + val pagesArr = document + .select("script:containsData(window.__pg)") + .first() + .html() + .trim() + .removePrefix("window.__pg = ") + .removeSuffix(";") + + val pagesJson = json.decodeFromString(pagesArr) + val pages = mutableListOf() + + pagesJson.forEach { page -> + val keys = servers.keys.filter { serverToUse.indexOf(it) >= 0 }.sortedBy { serverToUse.indexOf(it) } + val serversUrls = keys.map { + servers[it]?.jsonPrimitive?.contentOrNull + imgUrl + page.jsonObject["u"]!!.jsonPrimitive.content + }.joinToString(separator = ",,") { it } + pages.add(Page(page.jsonObject["p"]!!.jsonPrimitive.int, serversUrls)) + } + + return pages + } + + private fun checkImage(url: String): Boolean { + val response = client.newCall(Request.Builder().url(url).head().headers(headers).build()).execute() + return response.isSuccessful && (response.header("content-length", "0")?.toInt()!! > 320) + } + + override fun fetchImageUrl(page: Page): Observable { + if (page.imageUrl != null) { + return Observable.just(page.imageUrl) + } + + val urls = page.url.split(",,") + if (urls.size == 1) { + return Observable.just(urls[0]) + } + + return Observable.from(urls).filter { checkImage(it) }.first() + } + + override fun imageUrlParse(response: Response): String = "" + + private fun searchMangaByIdRequest(id: String): Request { + return GET("$baseUrl/$id", headers) + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return if (query.startsWith(PREFIX_SLUG_SEARCH)) { + val realQuery = query.removePrefix(PREFIX_SLUG_SEARCH) + client.newCall(searchMangaByIdRequest(realQuery)) + .asObservableSuccess() + .map { response -> + val details = mangaDetailsParse(response) + details.url = "/$realQuery" + MangasPage(listOf(details), false) + } + } else { + client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { response -> + searchMangaParse(response) + } + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (csrfToken.isEmpty()) { + val tokenResponse = client.newCall(popularMangaRequest(page)).execute() + val resBody = tokenResponse.body!!.string() + csrfToken = "_token\" content=\"(.*)\"".toRegex().find(resBody)!!.groups[1]!!.value + } + val url = "$baseUrl/filterlist?page=$page".toHttpUrlOrNull()!!.newBuilder() + if (query.isNotEmpty()) { + url.addQueryParameter("name", query) + } + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> + when (filter) { + is CategoryList -> filter.state.forEach { category -> + if (category.state) { + url.addQueryParameter("types[]", category.id) + } + } + is FormatList -> filter.state.forEach { forma -> + if (forma.state != Filter.TriState.STATE_IGNORE) { + url.addQueryParameter(if (forma.isIncluded()) "format[include][]" else "format[exclude][]", forma.id) + } + } + is StatusList -> filter.state.forEach { status -> + if (status.state) { + url.addQueryParameter("status[]", status.id) + } + } + is StatusTitleList -> filter.state.forEach { title -> + if (title.state) { + url.addQueryParameter("manga_status[]", title.id) + } + } + is GenreList -> filter.state.forEach { genre -> + if (genre.state != Filter.TriState.STATE_IGNORE) { + url.addQueryParameter(if (genre.isIncluded()) "genres[include][]" else "genres[exclude][]", genre.id) + } + } + is OrderBy -> { + url.addQueryParameter("dir", if (filter.state!!.ascending) "asc" else "desc") + url.addQueryParameter("sort", arrayOf("rate", "name", "views", "created_at", "last_chapter_at", "chap_count")[filter.state!!.index]) + } + is TagList -> filter.state.forEach { tag -> + if (tag.state != Filter.TriState.STATE_IGNORE) { + url.addQueryParameter(if (tag.isIncluded()) "tags[include][]" else "tags[exclude][]", tag.id) + } + } + } + } + return POST(url.toString(), catalogHeaders()) + } + + // Hack search method to add some results from search popup + override fun searchMangaParse(response: Response): MangasPage { + val searchRequest = response.request.url.queryParameter("name") + val mangas = mutableListOf() + + if (!searchRequest.isNullOrEmpty()) { + val popupSearchHeaders = headers + .newBuilder() + .add("Accept", "application/json, text/plain, */*") + .add("X-Requested-With", "XMLHttpRequest") + .build() + + // +200ms + val popup = client.newCall( + GET("$baseUrl/search?query=$searchRequest", popupSearchHeaders) + ) + .execute().body!!.string() + + val jsonList = json.decodeFromString(popup) + jsonList.forEach { + mangas.add(popularMangaFromElement(it)) + } + } + val searchedMangas = popularMangaParse(response) + + // Filtered out what find in popup search + mangas.addAll( + searchedMangas.mangas.filter { search -> + mangas.find { search.title == it.title } == null + } + ) + + return MangasPage(mangas, searchedMangas.hasNextPage) + } + + private class SearchFilter(name: String, val id: String) : Filter.TriState(name) + private class CheckFilter(name: String, val id: String) : Filter.CheckBox(name) + + private class CategoryList(categories: List) : Filter.Group("Тип", categories) + private class FormatList(formas: List) : Filter.Group("Формат выпуска", formas) + private class StatusList(statuses: List) : Filter.Group("Статус перевода", statuses) + private class StatusTitleList(titles: List) : Filter.Group("Статус тайтла", titles) + private class GenreList(genres: List) : Filter.Group("Жанры", genres) + private class TagList(tags: List) : Filter.Group("Теги", tags) + private class AgeList(ages: List) : Filter.Group("Возрастное ограничение", ages) + + override fun getFilterList() = FilterList( + OrderBy(), + CategoryList(getCategoryList()), + FormatList(getFormatList()), + GenreList(getGenreList()), + TagList(getTagList()), + StatusList(getStatusList()), + StatusTitleList(getStatusTitleList()) + ) + + private class OrderBy : Filter.Sort( + "Сортировка", + arrayOf("Рейтинг", "Имя", "Просмотры", "Дате добавления", "Дате обновления", "Кол-во глав"), + Selection(0, false) + ) + + /* + * Use console + * Object.entries(__FILTER_ITEMS__.types).map(([k, v]) => `SearchFilter("${v.label}", "${v.id}")`).join(',\n') + * on /manga-list + */ + private fun getCategoryList() = listOf( + CheckFilter("Манга", "1"), + CheckFilter("OEL-манга", "4"), + CheckFilter("Манхва", "5"), + CheckFilter("Маньхуа", "6"), + CheckFilter("Руманга", "8"), + CheckFilter("Комикс западный", "9") + ) + + private fun getFormatList() = listOf( + SearchFilter("4-кома (Ёнкома)", "1"), + SearchFilter("Сборник", "2"), + SearchFilter("Додзинси", "3"), + SearchFilter("Сингл", "4"), + SearchFilter("В цвете", "5"), + SearchFilter("Веб", "6") + ) + + /* + * Use console + * Object.entries(__FILTER_ITEMS__.status).map(([k, v]) => `SearchFilter("${v.label}", "${v.id}")`).join(',\n') + * on /manga-list + */ + private fun getStatusList() = listOf( + CheckFilter("Продолжается", "1"), + CheckFilter("Завершен", "2"), + CheckFilter("Заморожен", "3"), + CheckFilter("Заброшен", "4") + ) + + private fun getStatusTitleList() = listOf( + CheckFilter("Онгоинг", "1"), + CheckFilter("Завершён", "2"), + CheckFilter("Анонс", "3"), + CheckFilter("Приостановлен", "4"), + CheckFilter("Выпуск прекращён", "5"), + ) + + /* + * Use console + * __FILTER_ITEMS__.genres.map(it => `SearchFilter("${it.name}", "${it.id}")`).join(',\n') + * on /manga-list + */ + private fun getGenreList() = listOf( + SearchFilter("арт", "32"), + SearchFilter("боевик", "34"), + SearchFilter("боевые искусства", "35"), + SearchFilter("вампиры", "36"), + SearchFilter("гарем", "37"), + SearchFilter("гендерная интрига", "38"), + SearchFilter("героическое фэнтези", "39"), + SearchFilter("детектив", "40"), + SearchFilter("дзёсэй", "41"), + SearchFilter("драма", "43"), + SearchFilter("игра", "44"), + SearchFilter("исекай", "79"), + SearchFilter("история", "45"), + SearchFilter("киберпанк", "46"), + SearchFilter("кодомо", "76"), + SearchFilter("комедия", "47"), + SearchFilter("махо-сёдзё", "48"), + SearchFilter("меха", "49"), + SearchFilter("мистика", "50"), + SearchFilter("научная фантастика", "51"), + SearchFilter("омегаверс", "77"), + SearchFilter("повседневность", "52"), + SearchFilter("постапокалиптика", "53"), + SearchFilter("приключения", "54"), + SearchFilter("психология", "55"), + SearchFilter("романтика", "56"), + SearchFilter("самурайский боевик", "57"), + SearchFilter("сверхъестественное", "58"), + SearchFilter("сёдзё", "59"), + SearchFilter("сёдзё-ай", "60"), + SearchFilter("сёнэн", "61"), + SearchFilter("сёнэн-ай", "62"), + SearchFilter("спорт", "63"), + SearchFilter("сэйнэн", "64"), + SearchFilter("трагедия", "65"), + SearchFilter("триллер", "66"), + SearchFilter("ужасы", "67"), + SearchFilter("фантастика", "68"), + SearchFilter("фэнтези", "69"), + SearchFilter("школа", "70"), + SearchFilter("эротика", "71"), + SearchFilter("этти", "72"), + SearchFilter("юри", "73"), + SearchFilter("яой", "74") + ) + + private fun getTagList() = listOf( + SearchFilter("3D", "1"), + SearchFilter("Defloration", "287"), + SearchFilter("FPP(Вид от первого лица)", "289"), + SearchFilter("Footfuck", "5"), + SearchFilter("Handjob", "6"), + SearchFilter("Lactation", "7"), + SearchFilter("Living clothes", "284"), + SearchFilter("Mind break", "9"), + SearchFilter("Scat", "13"), + SearchFilter("Selfcest", "286"), + SearchFilter("Shemale", "220"), + SearchFilter("Tomboy", "14"), + SearchFilter("Unbirth", "283"), + SearchFilter("X-Ray", "15"), + SearchFilter("Алкоголь", "16"), + SearchFilter("Анал", "17"), + SearchFilter("Андроид", "18"), + SearchFilter("Анилингус", "19"), + SearchFilter("Анимация (GIF)", "350"), + SearchFilter("Арт", "20"), + SearchFilter("Ахэгао", "2"), + SearchFilter("БДСМ", "22"), + SearchFilter("Бакуню", "21"), + SearchFilter("Бара", "293"), + SearchFilter("Без проникновения", "336"), + SearchFilter("Без текста", "23"), + SearchFilter("Без трусиков", "24"), + SearchFilter("Без цензуры", "25"), + SearchFilter("Беременность", "26"), + SearchFilter("Бикини", "27"), + SearchFilter("Близнецы", "28"), + SearchFilter("Боди-арт", "29"), + SearchFilter("Больница", "30"), + SearchFilter("Большая грудь", "31"), + SearchFilter("Большая попка", "32"), + SearchFilter("Борьба", "33"), + SearchFilter("Буккакэ", "34"), + SearchFilter("В бассейне", "35"), + SearchFilter("В ванной", "36"), + SearchFilter("В государственном учреждении", "37"), + SearchFilter("В общественном месте", "38"), + SearchFilter("В очках", "8"), + SearchFilter("В первый раз", "39"), + SearchFilter("В транспорте", "40"), + SearchFilter("Вампиры", "41"), + SearchFilter("Вибратор", "42"), + SearchFilter("Втроём", "43"), + SearchFilter("Гипноз", "44"), + SearchFilter("Глубокий минет", "45"), + SearchFilter("Горячий источник", "46"), + SearchFilter("Групповой секс", "47"), + SearchFilter("Гуро", "307"), + SearchFilter("Гяру и Гангуро", "48"), + SearchFilter("Двойное проникновение", "49"), + SearchFilter("Девочки-волшебницы", "50"), + SearchFilter("Девушка-туалет", "51"), + SearchFilter("Демон", "52"), + SearchFilter("Дилдо", "53"), + SearchFilter("Домохозяйка", "54"), + SearchFilter("Дыра в стене", "55"), + SearchFilter("Жестокость", "56"), + SearchFilter("Золотой дождь", "57"), + SearchFilter("Зомби", "58"), + SearchFilter("Зоофилия", "351"), + SearchFilter("Зрелые женщины", "59"), + SearchFilter("Избиение", "223"), + SearchFilter("Измена", "60"), + SearchFilter("Изнасилование", "61"), + SearchFilter("Инопланетяне", "62"), + SearchFilter("Инцест", "63"), + SearchFilter("Исполнение желаний", "64"), + SearchFilter("Историческое", "65"), + SearchFilter("Камера", "66"), + SearchFilter("Кляп", "288"), + SearchFilter("Колготки", "67"), + SearchFilter("Косплей", "68"), + SearchFilter("Кримпай", "3"), + SearchFilter("Куннилингус", "69"), + SearchFilter("Купальники", "70"), + SearchFilter("ЛГБТ", "343"), + SearchFilter("Латекс и кожа", "71"), + SearchFilter("Магия", "72"), + SearchFilter("Маленькая грудь", "73"), + SearchFilter("Мастурбация", "74"), + SearchFilter("Медсестра", "221"), + SearchFilter("Мейдочка", "75"), + SearchFilter("Мерзкий дядька", "76"), + SearchFilter("Милф", "77"), + SearchFilter("Много девушек", "78"), + SearchFilter("Много спермы", "79"), + SearchFilter("Молоко", "80"), + SearchFilter("Монашка", "353"), + SearchFilter("Монстродевушки", "81"), + SearchFilter("Монстры", "82"), + SearchFilter("Мочеиспускание", "83"), + SearchFilter("На природе", "84"), + SearchFilter("Наблюдение", "85"), + SearchFilter("Насекомые", "285"), + SearchFilter("Небритая киска", "86"), + SearchFilter("Небритые подмышки", "87"), + SearchFilter("Нетораре", "88"), + SearchFilter("Нэтори", "11"), + SearchFilter("Обмен телами", "89"), + SearchFilter("Обычный секс", "90"), + SearchFilter("Огромная грудь", "91"), + SearchFilter("Огромный член", "92"), + SearchFilter("Омораси", "93"), + SearchFilter("Оральный секс", "94"), + SearchFilter("Орки", "95"), + SearchFilter("Остановка времени", "296"), + SearchFilter("Пайзури", "96"), + SearchFilter("Парень пассив", "97"), + SearchFilter("Переодевание", "98"), + SearchFilter("Пирсинг", "308"), + SearchFilter("Пляж", "99"), + SearchFilter("Повседневность", "100"), + SearchFilter("Подвязки", "282"), + SearchFilter("Подглядывание", "101"), + SearchFilter("Подчинение", "102"), + SearchFilter("Похищение", "103"), + SearchFilter("Превозмогание", "104"), + SearchFilter("Принуждение", "105"), + SearchFilter("Прозрачная одежда", "106"), + SearchFilter("Проституция", "107"), + SearchFilter("Психические отклонения", "108"), + SearchFilter("Публично", "109"), + SearchFilter("Пытки", "224"), + SearchFilter("Пьяные", "110"), + SearchFilter("Рабы", "356"), + SearchFilter("Рабыни", "111"), + SearchFilter("С Сюжетом", "337"), + SearchFilter("Сuminside", "4"), + SearchFilter("Секс-игрушки", "112"), + SearchFilter("Сексуально возбуждённая", "113"), + SearchFilter("Сибари", "114"), + SearchFilter("Спортивная форма", "117"), + SearchFilter("Спортивное тело", "335"), + SearchFilter("Спящие", "118"), + SearchFilter("Страпон", "119"), + SearchFilter("Суккуб", "120"), + SearchFilter("Темнокожие", "121"), + SearchFilter("Тентакли", "122"), + SearchFilter("Толстушки", "123"), + SearchFilter("Трагедия", "124"), + SearchFilter("Трап", "125"), + SearchFilter("Ужасы", "126"), + SearchFilter("Униформа", "127"), + SearchFilter("Учитель и ученик", "352"), + SearchFilter("Ушастые", "128"), + SearchFilter("Фантазии", "129"), + SearchFilter("Фемдом", "130"), + SearchFilter("Фестиваль", "131"), + SearchFilter("Фетиш", "132"), + SearchFilter("Фистинг", "133"), + SearchFilter("Фурри", "134"), + SearchFilter("Футанари", "136"), + SearchFilter("Футанари имеет парня", "137"), + SearchFilter("Цельный купальник", "138"), + SearchFilter("Цундэрэ", "139"), + SearchFilter("Чикан", "140"), + SearchFilter("Чулки", "141"), + SearchFilter("Шлюха", "142"), + SearchFilter("Эксгибиционизм", "143"), + SearchFilter("Эльф", "144"), + SearchFilter("Юные", "145"), + SearchFilter("Яндэрэ", "146") + ) + + companion object { + const val PREFIX_SLUG_SEARCH = "slug:" + private const val SERVER_PREF = "MangaLibImageServer" + private const val SERVER_PREF_Title = "Сервер изображений" + + private const val SORTING_PREF = "MangaLibSorting" + private const val SORTING_PREF_Title = "Способ выбора переводчиков" + + private const val LANGUAGE_PREF = "MangaLibTitleLanguage" + private const val LANGUAGE_PREF_Title = "Выбор языка на обложке" + + private const val COVER_URL = "https://staticlib.me" + } + + private var server: String? = preferences.getString(SERVER_PREF, null) + private var titleLanguage: String? = preferences.getString(LANGUAGE_PREF, null) + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val serverPref = ListPreference(screen.context).apply { + key = SERVER_PREF + title = SERVER_PREF_Title + entries = arrayOf("Основной", "Второй (тестовый)", "Третий (эконом трафика)", "Авто") + entryValues = arrayOf("secondary", "fourth", "compress", "auto") + summary = "%s" + setDefaultValue("auto") + setOnPreferenceChangeListener { _, newValue -> + server = newValue.toString() + true + } + } + + val sortingPref = ListPreference(screen.context).apply { + key = SORTING_PREF + title = SORTING_PREF_Title + entries = arrayOf( + "Полный список (без повторных переводов)", "Все переводы (друг за другом)", + "Наибольшее число глав", "Активный перевод" + ) + entryValues = arrayOf("ms_mixing", "ms_combining", "ms_largest", "ms_active") + summary = "%s" + setDefaultValue("ms_mixing") + setOnPreferenceChangeListener { _, newValue -> + val selected = newValue as String + preferences.edit().putString(SORTING_PREF, selected).commit() + } + } + val titleLanguagePref = ListPreference(screen.context).apply { + key = LANGUAGE_PREF + title = LANGUAGE_PREF_Title + entries = arrayOf("Английский (транскрипция)", "Русский") + entryValues = arrayOf("eng", "rus") + summary = "%s" + setDefaultValue("eng") + setOnPreferenceChangeListener { _, newValue -> + titleLanguage = newValue.toString() + val warning = "Если язык обложки не изменился очистите базу данных в приложении (Настройки -> Дополнительно -> Очистить базу данных)" + Toast.makeText(screen.context, warning, Toast.LENGTH_LONG).show() + true + } + } + screen.addPreference(serverPref) + screen.addPreference(sortingPref) + screen.addPreference(titleLanguagePref) + } +} diff --git a/src/ru/libhentai/src/eu/kanade/tachiyomi/extension/ru/libhentai/LibHentaiActivity.kt b/src/ru/libhentai/src/eu/kanade/tachiyomi/extension/ru/libhentai/LibHentaiActivity.kt new file mode 100644 index 000000000..4b29428c0 --- /dev/null +++ b/src/ru/libhentai/src/eu/kanade/tachiyomi/extension/ru/libhentai/LibHentaiActivity.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.extension.ru.libhentai + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +/** + * Springboard that accepts https://hentailib.me/xxx intents and redirects them to + * the main tachiyomi process. The idea is to not install the intent filter unless + * you have this extension installed, but still let the main tachiyomi app control + * things. + */ +class LibHentaiActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 0) { + val titleid = pathSegments[0] + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${LibHentai.PREFIX_SLUG_SEARCH}$titleid") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("LibHentaiActivity", e.toString()) + } + } else { + Log.e("LibHentaiActivity", "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } +}