diff --git a/src/all/asmhentai/AndroidManifest.xml b/lib-multisrc/galleryadults/AndroidManifest.xml similarity index 71% rename from src/all/asmhentai/AndroidManifest.xml rename to lib-multisrc/galleryadults/AndroidManifest.xml index 9de41aa38..aefaa54ac 100644 --- a/src/all/asmhentai/AndroidManifest.xml +++ b/lib-multisrc/galleryadults/AndroidManifest.xml @@ -1,19 +1,21 @@ + + + android:host="${SOURCEHOST}" + android:pathPattern="/g.*/..*/" + android:scheme="${SOURCESCHEME}" /> diff --git a/lib-multisrc/galleryadults/build.gradle.kts b/lib-multisrc/galleryadults/build.gradle.kts new file mode 100644 index 000000000..7dd8f08b9 --- /dev/null +++ b/lib-multisrc/galleryadults/build.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("lib-multisrc") +} + +baseVersionCode = 0 diff --git a/lib-multisrc/galleryadults/res/mipmap-hdpi/ic_launcher.png b/lib-multisrc/galleryadults/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..3762b52b3 Binary files /dev/null and b/lib-multisrc/galleryadults/res/mipmap-hdpi/ic_launcher.png differ diff --git a/lib-multisrc/galleryadults/res/mipmap-mdpi/ic_launcher.png b/lib-multisrc/galleryadults/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..59d965e69 Binary files /dev/null and b/lib-multisrc/galleryadults/res/mipmap-mdpi/ic_launcher.png differ diff --git a/lib-multisrc/galleryadults/res/mipmap-xhdpi/ic_launcher.png b/lib-multisrc/galleryadults/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..749a00d6a Binary files /dev/null and b/lib-multisrc/galleryadults/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/lib-multisrc/galleryadults/res/mipmap-xxhdpi/ic_launcher.png b/lib-multisrc/galleryadults/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..fd6c94e4f Binary files /dev/null and b/lib-multisrc/galleryadults/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/lib-multisrc/galleryadults/res/mipmap-xxxhdpi/ic_launcher.png b/lib-multisrc/galleryadults/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..988c67af6 Binary files /dev/null and b/lib-multisrc/galleryadults/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt new file mode 100644 index 000000000..891961ea5 --- /dev/null +++ b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdults.kt @@ -0,0 +1,850 @@ +package eu.kanade.tachiyomi.multisrc.galleryadults + +import android.app.Application +import android.content.SharedPreferences +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.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.model.UpdateStrategy +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import okhttp3.FormBody +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +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 + +abstract class GalleryAdults( + override val name: String, + override val baseUrl: String, + override val lang: String = "all", + protected open val mangaLang: String = LANGUAGE_MULTI, + protected val simpleDateFormat: SimpleDateFormat? = null, +) : ConfigurableSource, ParsedHttpSource() { + + override val client: OkHttpClient = network.cloudflareClient + + protected open val xhrHeaders = headers.newBuilder() + .add("X-Requested-With", "XMLHttpRequest") + .build() + + protected val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + protected val SharedPreferences.shortTitle + get() = getBoolean(PREF_SHORT_TITLE, false) + + private val shortenTitleRegex = Regex("""(\[[^]]*]|[({][^)}]*[)}])""") + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + SwitchPreferenceCompat(screen.context).apply { + key = PREF_SHORT_TITLE + title = "Display Short Titles" + summaryOff = "Showing Long Titles" + summaryOn = "Showing short Titles" + setDefaultValue(false) + }.also(screen::addPreference) + } + + protected open fun Element.mangaTitle(selector: String = ".caption"): String? = + mangaFullTitle(selector).let { + if (preferences.shortTitle) it?.shortenTitle() else it + } + + protected fun Element.mangaFullTitle(selector: String) = + selectFirst(selector)?.text() + + private fun String.shortenTitle() = this.replace(shortenTitleRegex, "").trim() + + /* List detail */ + protected class SMangaDto( + val title: String, + val url: String, + val thumbnail: String?, + val lang: String, + ) + + protected open fun Element.mangaUrl() = + selectFirst(".inner_thumb a")?.attr("abs:href") + + protected open fun Element.mangaThumbnail() = + selectFirst(".inner_thumb img")?.imgAttr() + + // Overwrite this to filter other languages' manga from search result. + // Default to [mangaLang] won't filter anything + protected open fun Element.mangaLang() = mangaLang + + protected open fun HttpUrl.Builder.addPageUri(page: Int): HttpUrl.Builder { + val url = toString() + if (!url.endsWith('/') && !url.contains('?')) { + addPathSegment("") // trailing slash (/) + } + if (page > 1) addQueryParameter("page", page.toString()) + return this + } + + /* Popular */ + override fun popularMangaRequest(page: Int): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + if (mangaLang.isNotBlank()) addPathSegments("language/$mangaLang") + if (supportsLatest) addPathSegment("popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + override fun popularMangaSelector() = "div.thumb" + + override fun popularMangaFromElement(element: Element): SManga { + return SManga.create().apply { + title = element.mangaTitle()!! + setUrlWithoutDomain(element.mangaUrl()!!) + thumbnail_url = element.mangaThumbnail() + } + } + + override fun popularMangaNextPageSelector() = ".pagination li.active + li:not(.disabled)" + + /* Latest */ + override fun latestUpdatesRequest(page: Int): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + if (mangaLang.isNotBlank()) addPathSegments("language/$mangaLang") + addPageUri(page) + } + return GET(url.build(), headers) + } + + override fun latestUpdatesSelector() = popularMangaSelector() + + override fun latestUpdatesFromElement(element: Element) = popularMangaFromElement(element) + + override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() + + /* Search */ + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return when { + query.startsWith(PREFIX_ID_SEARCH) -> { + val id = query.removePrefix(PREFIX_ID_SEARCH) + client.newCall(searchMangaByIdRequest(id)) + .asObservableSuccess() + .map { response -> searchMangaByIdParse(response, id) } + } + query.toIntOrNull() != null -> { + client.newCall(searchMangaByIdRequest(query)) + .asObservableSuccess() + .map { response -> searchMangaByIdParse(response, query) } + } + else -> { + client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { response -> searchMangaParse(response) } + } + } + } + + protected open val idPrefixUri = "g" + + protected open fun searchMangaByIdRequest(id: String): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment(idPrefixUri) + addPathSegments("$id/") + } + return GET(url.build(), headers) + } + + protected open fun searchMangaByIdParse(response: Response, id: String): MangasPage { + val details = mangaDetailsParse(response.asJsoup()) + details.url = "/$idPrefixUri/$id/" + return MangasPage(listOf(details), false) + } + + protected open val useIntermediateSearch: Boolean = false + protected open val supportAdvancedSearch: Boolean = false + protected open val supportSpeechless: Boolean = false + private val useBasicSearch: Boolean + get() = !useIntermediateSearch + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + val favoriteFilter = filters.filterIsInstance().firstOrNull() + + // Speechless + val speechlessFilter = filters.filterIsInstance().firstOrNull() + + // Advanced search + val advancedSearchFilters = filters.filterIsInstance() + + return when { + favoriteFilter?.state == true -> + favoriteFilterSearchRequest(page, query, filters) + supportSpeechless && speechlessFilter?.state == true -> + speechlessFilterSearchRequest(page, query, filters) + supportAdvancedSearch && advancedSearchFilters.any { it.state.isNotBlank() } -> + advancedSearchRequest(page, query, filters) + selectedGenres.size == 1 && query.isBlank() -> + tagBrowsingSearchRequest(page, query, filters) + useIntermediateSearch -> + intermediateSearchRequest(page, query, filters) + useBasicSearch && (selectedGenres.size > 1 || query.isNotBlank()) -> + basicSearchRequest(page, query, filters) + sortOrderFilter?.state == 1 -> + latestUpdatesRequest(page) + else -> + popularMangaRequest(page) + } + } + + /** + * Browsing user's personal favorites saved on site. This requires login in view WebView. + */ + protected open fun favoriteFilterSearchRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$baseUrl/$favoritePath".toHttpUrl().newBuilder() + return POST( + url.build().toString(), + xhrHeaders, + FormBody.Builder() + .add("page", page.toString()) + .build(), + ) + } + + /** + * Browsing speechless titles. Some sites exclude speechless titles from normal search and + * allow browsing separately. + */ + protected open fun speechlessFilterSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("language") + addPathSegment(LANGUAGE_SPEECHLESS) + if (sortOrderFilter?.state == 0) addPathSegment("popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + protected open fun tagBrowsingSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + + // Browsing single tag's catalog + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegment("tag") + addPathSegment(selectedGenres.single().uri) + if (sortOrderFilter?.state == 0) addPathSegment("popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + /** + * Basic Search: support query string with multiple-genres filter by adding genres to query string. + */ + protected open fun basicSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegments("search/") + addEncodedQueryParameter("q", buildQueryString(selectedGenres.map { it.name }, query)) + // Search results sorting is not supported by AsmHentai + if (sortOrderFilter?.state == 0) addQueryParameter("sort", "popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + /** + * This supports filter query search with languages, categories (manga, doujinshi...) + * with additional sort orders. + */ + protected open fun intermediateSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + + // Intermediate search + val categoryFilters = filters.filterIsInstance().firstOrNull() + + // Only for query string or multiple tags + val url = "$baseUrl/search".toHttpUrl().newBuilder().apply { + getSortOrderURIs().forEachIndexed { index, pair -> + addQueryParameter(pair.second, toBinary(sortOrderFilter?.state == index)) + } + categoryFilters?.state?.forEach { + addQueryParameter(it.uri, toBinary(it.state)) + } + getLanguageURIs().forEach { pair -> + addQueryParameter( + pair.second, + toBinary(mangaLang == pair.first || mangaLang == LANGUAGE_MULTI), + ) + } + addEncodedQueryParameter("key", buildQueryString(selectedGenres.map { it.name }, query)) + addPageUri(page) + } + return GET(url.build()) + } + + /** + * Advanced Search normally won't support search for string but allow include/exclude specific + * tags/artists/groups/parodies/characters + */ + protected open fun advancedSearchRequest(page: Int, query: String, filters: FilterList): Request { + // Basic search + val sortOrderFilter = filters.filterIsInstance().firstOrNull() + val genresFilter = filters.filterIsInstance().firstOrNull() + val selectedGenres = genresFilter?.state?.filter { it.state } ?: emptyList() + + // Intermediate search + val categoryFilters = filters.filterIsInstance().firstOrNull() + // Advanced search + val advancedSearchFilters = filters.filterIsInstance() + + val url = "$baseUrl/advsearch".toHttpUrl().newBuilder().apply { + getSortOrderURIs().forEachIndexed { index, pair -> + addQueryParameter(pair.second, toBinary(sortOrderFilter?.state == index)) + } + categoryFilters?.state?.forEach { + addQueryParameter(it.uri, toBinary(it.state)) + } + getLanguageURIs().forEach { pair -> + addQueryParameter( + pair.second, + toBinary( + mangaLang == pair.first || + mangaLang == LANGUAGE_MULTI, + ), + ) + } + + // Build this query string: +tag:"bat+man"+-tag:"cat"+artist:"Joe"... + // +tag must be encoded into %2Btag while the rest are not needed to encode + val keys = emptyList().toMutableList() + keys.addAll(selectedGenres.map { "%2Btag:\"${it.name}\"" }) + advancedSearchFilters.forEach { filter -> + val key = when (filter) { + is TagsFilter -> "tag" + is ParodiesFilter -> "parody" + is ArtistsFilter -> "artist" + is CharactersFilter -> "character" + is GroupsFilter -> "group" + else -> null + } + if (key != null) { + keys.addAll( + filter.state.trim() + .replace(regexSpaceNotAfterComma, "+") + .replace(" ", "") + .split(',') + .mapNotNull { + val match = regexExcludeTerm.find(it) + match?.groupValues?.let { groups -> + "${if (groups[1].isNotBlank()) "-" else "%2B"}$key:\"${groups[2]}\"" + } + }, + ) + } + } + addEncodedQueryParameter("key", keys.joinToString("+")) + addPageUri(page) + } + return GET(url.build()) + } + + /** + * Convert space( ) typed in search-box into plus(+) in URL. Then: + * - uses plus(+) to search for exact match + * - use comma(,) for separate terms, as AND condition. + * Plus(+) after comma(,) doesn't have any effect. + */ + protected open fun buildQueryString(tags: List, query: String): String { + return (tags + query).filterNot { it.isBlank() }.joinToString(",") { + // any space except after a comma (we're going to replace spaces only between words) + it.trim() + .replace(regexSpaceNotAfterComma, "+") + .replace(" ", "") + } + } + + protected open val favoritePath = "includes/user_favs.php" + + protected open fun loginRequired(document: Document, url: String): Boolean { + return ( + url.contains("/login/") && + document.select("input[value=Login]").isNotEmpty() + ) + } + + override fun searchMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + if (loginRequired(document, response.request.url.toString())) { + throw Exception("Log in via WebView to view favorites") + } else { + val hasNextPage = document.select(searchMangaNextPageSelector()).isNotEmpty() + val mangas = document.select(searchMangaSelector()) + .map { + SMangaDto( + title = it.mangaTitle()!!, + url = it.mangaUrl()!!, + thumbnail = it.mangaThumbnail(), + lang = it.mangaLang(), + ) + } + .let { unfiltered -> + val results = unfiltered.filter { mangaLang.isBlank() || it.lang == mangaLang } + // return at least 1 title if all mangas in current page is of other languages + if (results.isEmpty() && hasNextPage) listOf(unfiltered[0]) else results + } + .map { + SManga.create().apply { + title = it.title + setUrlWithoutDomain(it.url) + thumbnail_url = it.thumbnail + } + } + + return MangasPage(mangas, hasNextPage) + } + } + + override fun searchMangaSelector() = popularMangaSelector() + + override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) + + override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() + + /* Details */ + protected open fun Element.getCover() = + selectFirst(".cover img")?.imgAttr() + + protected open fun Element.getInfo(tag: String): String { + return select("ul.${tag.lowercase()} a") + .joinToString { it.ownText() } + } + + protected open fun Element.getDescription(): String = ( + listOf("Parodies", "Characters", "Languages", "Categories") + .mapNotNull { tag -> + getInfo(tag) + .let { if (it.isNotBlank()) "$tag: $it" else null } + } + + listOfNotNull( + selectFirst(".pages:contains(Pages:)")?.ownText(), + ) + ) + .joinToString("\n\n") + + protected open val mangaDetailInfoSelector = ".gallery_top" + protected open val timeSelector = "time[datetime]" + + protected open fun Element.getTime(): Long { + return selectFirst(timeSelector) + ?.attr("datetime") + .toDate(simpleDateFormat) + } + + override fun mangaDetailsParse(document: Document): SManga { + return document.selectFirst(mangaDetailInfoSelector)!!.run { + SManga.create().apply { + update_strategy = UpdateStrategy.ONLY_FETCH_ONCE + status = SManga.COMPLETED + title = mangaTitle("h1")!! + thumbnail_url = getCover() + genre = getInfo("Tags") + author = getInfo("Artists") + description = getDescription() + } + } + } + + /* Chapters */ + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + return listOf( + SChapter.create().apply { + name = "Chapter" + scanlator = document.selectFirst(mangaDetailInfoSelector) + ?.getInfo("Groups") + date_upload = document.getTime() + setUrlWithoutDomain(response.request.url.encodedPath) + }, + ) + } + + override fun chapterListSelector() = throw UnsupportedOperationException() + + override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() + + /* Pages */ + protected open fun Document.inputIdValueOf(string: String): String { + return select("input[id=$string]").attr("value") + } + + protected open val galleryIdSelector = "gallery_id" + protected open val loadIdSelector = "load_id" + protected open val loadDirSelector = "load_dir" + protected open val totalPagesSelector = "load_pages" + protected open val pageUri = "g" + protected open val pageSelector = ".gallery_thumb" + + protected open val pagesRequest = "inc/thumbs_loader.php" + + private val jsonFormat: Json by injectLazy() + + protected open fun getServer(document: Document, galleryId: String): String { + val cover = document.getCover() + return cover!!.toHttpUrl().host + } + + override fun pageListParse(document: Document): List { + val json = document.selectFirst("script:containsData(parseJSON)")?.data() + ?.substringAfter("$.parseJSON('") + ?.substringBefore("');")?.trim() + + if (json != null) { + val loadDir = document.inputIdValueOf(loadDirSelector) + val loadId = document.inputIdValueOf(loadIdSelector) + val galleryId = document.inputIdValueOf(galleryIdSelector) + val pageUrl = "$baseUrl/$pageUri/$galleryId" + + val randomServer = getServer(document, galleryId) + val imagesUri = "https://$randomServer/$loadDir/$loadId" + + try { + val pages = mutableListOf() + val images = jsonFormat.parseToJsonElement(json).jsonObject + + // JSON string in this form: {"1":"j,1100,1148","2":"j,728,689",... + for (image in images) { + val ext = image.value.toString().replace("\"", "").split(",")[0] + val imageExt = when (ext) { + "p" -> "png" + "b" -> "bmp" + "g" -> "gif" + else -> "jpg" + } + val idx = image.key.toInt() + pages.add( + Page( + index = idx, + imageUrl = "$imagesUri/${image.key}.$imageExt", + url = "$pageUrl/$idx/", + ), + ) + } + return pages + } catch (e: SerializationException) { + Log.e("GalleryAdults", "Failed to decode JSON") + return this.pageListParseAlternative(document) + } + } else { + return this.pageListParseAlternative(document) + } + } + + /** + * Overwrite this to force extension not blindly converting thumbnails to full image + * with simply removing the trailing "t" from file name. Instead, it will open each page, + * one by one, then parsing for actual image's URL. + * This will be much slower but guaranteed work. + */ + protected open val parsingImagePageByPage: Boolean = false + + /** + * Either: + * - Load all thumbnails then convert thumbnails to full images. + * - Or request then parse for a list of manga's page's URL, + * which will then request one by one to parse for page's image's URL using [imageUrlParse]. + */ + protected open fun pageListParseAlternative(document: Document): List { + // input only exists if pages > 10 and have to make a request to get the other thumbnails + val totalPages = document.inputIdValueOf(totalPagesSelector) + val galleryId = document.inputIdValueOf(galleryIdSelector) + val pageUrl = "$baseUrl/$pageUri/$galleryId" + + val pages = document.select("$pageSelector a") + .map { + if (parsingImagePageByPage) { + it.absUrl("href") + } else { + it.selectFirst("img")!!.imgAttr() + } + } + .toMutableList() + + if (totalPages.isNotBlank()) { + val form = pageRequestForm(document, totalPages) + + val morePages = client.newCall(POST("$baseUrl/$pagesRequest", xhrHeaders, form)) + .execute() + .asJsoup() + .select("a") + .map { + if (parsingImagePageByPage) { + it.absUrl("href") + } else { + it.selectFirst("img")!!.imgAttr() + } + } + if (morePages.isNotEmpty()) { + pages.addAll(morePages) + } else { + return pageListParseDummy(document) + } + } + + return pages.mapIndexed { idx, url -> + if (parsingImagePageByPage) { + Page(idx, url) + } else { + Page( + index = idx, + imageUrl = url.thumbnailToFull(), + url = "$pageUrl/$idx/", + ) + } + } + } + + /** + * Generate all images using `totalPages`. Supposedly they are sequential. + * Use in case any extension doesn't know how to request for "All thumbnails" + */ + protected open fun pageListParseDummy(document: Document): List { + val loadDir = document.inputIdValueOf(loadDirSelector) + val loadId = document.inputIdValueOf(loadIdSelector) + val galleryId = document.inputIdValueOf(galleryIdSelector) + val pageUrl = "$baseUrl/$pageUri/$galleryId" + + val randomServer = getServer(document, galleryId) + val imagesUri = "https://$randomServer/$loadDir/$loadId" + + val images = document.select("$pageSelector img") + val thumbUrls = images.map { it.imgAttr() }.toMutableList() + + // totalPages only exists if pages > 10 and have to make a request to get the other thumbnails + val totalPages = document.inputIdValueOf(totalPagesSelector) + + if (totalPages.isNotBlank()) { + val imagesExt = images.first()?.imgAttr()!! + .substringAfterLast('.') + + thumbUrls.addAll( + listOf((images.size + 1)..totalPages.toInt()).flatten().map { + "$imagesUri/${it}t.$imagesExt" + }, + ) + } + return thumbUrls.mapIndexed { idx, url -> + Page( + index = idx, + imageUrl = url.thumbnailToFull(), + url = "$pageUrl/$idx/", + ) + } + } + + protected open fun pageRequestForm(document: Document, totalPages: String): FormBody = + FormBody.Builder() + .add("u_id", document.inputIdValueOf(galleryIdSelector)) + .add("g_id", document.inputIdValueOf(loadIdSelector)) + .add("img_dir", document.inputIdValueOf(loadDirSelector)) + .add("visible_pages", "10") + .add("total_pages", totalPages) + .add("type", "2") // 1 would be "more", 2 is "all remaining" + .build() + + override fun imageUrlParse(document: Document): String { + return document.selectFirst("img#gimg, img#fimg")?.imgAttr()!! + } + + /* Filters */ + private val scope = CoroutineScope(Dispatchers.IO) + private fun launchIO(block: () -> Unit) = scope.launch { block() } + private var tagsFetchAttempt = 0 + private var genres = emptyList() + + private fun tagsRequest(page: Int): Request { + val url = baseUrl.toHttpUrl().newBuilder().apply { + addPathSegments("tags/popular") + addPageUri(page) + } + return GET(url.build(), headers) + } + + protected open fun tagsParser(document: Document): List> { + return document.select(".list_tags .tag_item") + .mapNotNull { + Pair( + it.selectFirst("h3.list_tag")?.ownText() ?: "", + it.select("a").attr("href") + .removeSuffix("/").substringAfterLast('/'), + ) + } + } + + private fun getGenres() { + if (genres.isEmpty() && tagsFetchAttempt < 3) { + launchIO { + val tags = mutableListOf>() + runBlocking { + val jobsPool = mutableListOf() + // Get first 3 pages + (1..3).forEach { page -> + jobsPool.add( + launchIO { + runCatching { + tags.addAll( + client.newCall(tagsRequest(page)) + .execute().asJsoup().let { tagsParser(it) }, + ) + } + }, + ) + } + jobsPool.joinAll() + genres = tags.sortedWith(compareBy { it.first }).map { Genre(it.first, it.second) } + } + + tagsFetchAttempt++ + } + } + } + + override fun getFilterList(): FilterList { + getGenres() + val filters = emptyList>().toMutableList() + if (useIntermediateSearch) { + filters.add(Filter.Header("HINT: Separate search term with comma (,)")) + } + + filters.add(SortOrderFilter(getSortOrderURIs())) + + if (genres.isEmpty()) { + filters.add(Filter.Header("Press 'reset' to attempt to load tags")) + } else { + filters.add(GenresFilter(genres)) + } + + if (useIntermediateSearch) { + filters.addAll( + listOf( + Filter.Separator(), + CategoryFilters(getCategoryURIs()), + ), + ) + } + + if (supportAdvancedSearch) { + filters.addAll( + listOf( + Filter.Separator(), + Filter.Header("Advanced filters will ignore query search. Separate terms by comma (,) and precede term with minus (-) to exclude."), + TagsFilter(), + ParodiesFilter(), + ArtistsFilter(), + CharactersFilter(), + GroupsFilter(), + ), + ) + } + + filters.add(Filter.Separator()) + + if (supportSpeechless) { + filters.add(SpeechlessFilter()) + } + filters.add(FavoriteFilter()) + + return FilterList(filters) + } + + protected open fun getSortOrderURIs() = listOf( + Pair("Popular", "pp"), + Pair("Latest", "lt"), + ) + if (useIntermediateSearch || supportAdvancedSearch) { + listOf( + Pair("Downloads", "dl"), + Pair("Top Rated", "tr"), + ) + } else { + emptyList() + } + + protected open fun getCategoryURIs() = listOf( + SearchFlagFilter("Manga", "m"), + SearchFlagFilter("Doujinshi", "d"), + SearchFlagFilter("Western", "w"), + SearchFlagFilter("Image Set", "i"), + SearchFlagFilter("Artist CG", "a"), + SearchFlagFilter("Game CG", "g"), + ) + + protected open fun getLanguageURIs() = listOf( + Pair(LANGUAGE_ENGLISH, "en"), + Pair(LANGUAGE_JAPANESE, "jp"), + Pair(LANGUAGE_SPANISH, "es"), + Pair(LANGUAGE_FRENCH, "fr"), + Pair(LANGUAGE_KOREAN, "kr"), + Pair(LANGUAGE_GERMAN, "de"), + Pair(LANGUAGE_RUSSIAN, "ru"), + ) + + companion object { + const val PREFIX_ID_SEARCH = "id:" + + private const val PREF_SHORT_TITLE = "pref_short_title" + + // references to be used in factory + const val LANGUAGE_MULTI = "" + const val LANGUAGE_ENGLISH = "english" + const val LANGUAGE_JAPANESE = "japanese" + const val LANGUAGE_CHINESE = "chinese" + const val LANGUAGE_KOREAN = "korean" + const val LANGUAGE_SPANISH = "spanish" + const val LANGUAGE_FRENCH = "french" + const val LANGUAGE_GERMAN = "german" + const val LANGUAGE_RUSSIAN = "russian" + const val LANGUAGE_SPEECHLESS = "speechless" + const val LANGUAGE_TRANSLATED = "translated" + } +} diff --git a/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsFilters.kt b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsFilters.kt new file mode 100644 index 000000000..23008a3e0 --- /dev/null +++ b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsFilters.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.multisrc.galleryadults + +import eu.kanade.tachiyomi.source.model.Filter + +class Genre(name: String, val uri: String) : Filter.CheckBox(name) +class GenresFilter(genres: List) : Filter.Group( + "Tags", + genres.map { Genre(it.name, it.uri) }, +) + +class SortOrderFilter(sortOrderURIs: List>) : + Filter.Select("Sort By", sortOrderURIs.map { it.first }.toTypedArray()) + +class FavoriteFilter : Filter.CheckBox("Show favorites only (login via WebView)", false) + +// Speechless +class SpeechlessFilter : Filter.CheckBox("Show speechless items only", false) + +// Intermediate search +class SearchFlagFilter(name: String, val uri: String, state: Boolean = true) : Filter.CheckBox(name, state) +class CategoryFilters(flags: List) : Filter.Group("Categories", flags) + +// Advance search +abstract class AdvancedTextFilter(name: String) : Filter.Text(name) +class TagsFilter : AdvancedTextFilter("Tags") +class ParodiesFilter : AdvancedTextFilter("Parodies") +class ArtistsFilter : AdvancedTextFilter("Artists") +class CharactersFilter : AdvancedTextFilter("Characters") +class GroupsFilter : AdvancedTextFilter("Groups") diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUrlActivity.kt similarity index 63% rename from src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt rename to lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUrlActivity.kt index c984eae77..0aa18cd52 100644 --- a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHUrlActivity.kt +++ b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUrlActivity.kt @@ -1,4 +1,4 @@ -package eu.kanade.tachiyomi.extension.all.asmhentai +package eu.kanade.tachiyomi.multisrc.galleryadults import android.app.Activity import android.content.ActivityNotFoundException @@ -8,27 +8,27 @@ import android.util.Log import kotlin.system.exitProcess /** - * Springboard that accepts https://asmhentai.com/g/xxxxxx intents and redirects them to - * the main Tachiyomi process. + * Springboard that accepts https:///g/xxxxxx intents and redirects them to main app process. */ -class ASMHUrlActivity : Activity() { +class GalleryAdultsUrlActivity : Activity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val pathSegments = intent?.data?.pathSegments if (pathSegments != null && pathSegments.size > 1) { + val id = pathSegments[1] val mainIntent = Intent().apply { action = "eu.kanade.tachiyomi.SEARCH" - putExtra("query", "${AsmHentai.PREFIX_ID_SEARCH}${pathSegments[1]}") + putExtra("query", "${GalleryAdults.PREFIX_ID_SEARCH}$id") putExtra("filter", packageName) } try { startActivity(mainIntent) } catch (e: ActivityNotFoundException) { - Log.e("ASMHUrlActivity", e.toString()) + Log.e("GalleryAdultsUrl", e.toString()) } } else { - Log.e("ASMHUrlActivity", "could not parse uri from intent $intent") + Log.e("GalleryAdultsUrl", "could not parse uri from intent $intent") } finish() diff --git a/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUtils.kt b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUtils.kt new file mode 100644 index 000000000..49c84465f --- /dev/null +++ b/lib-multisrc/galleryadults/src/eu/kanade/tachiyomi/multisrc/galleryadults/GalleryAdultsUtils.kt @@ -0,0 +1,144 @@ +package eu.kanade.tachiyomi.multisrc.galleryadults + +import org.jsoup.nodes.Element +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar + +// any space except after a comma (we're going to replace spaces only between words) +val regexSpaceNotAfterComma = Regex("""(? absUrl("data-cfsrc") + hasAttr("data-src") -> absUrl("data-src") + hasAttr("data-lazy-src") -> absUrl("data-lazy-src") + hasAttr("srcset") -> absUrl("srcset").substringBefore(" ") + else -> absUrl("src") +}!! + +fun Element.cleanTag(): String = text().cleanTag() +fun String.cleanTag(): String = replace(regexTagCountNumber, "").trim() + +// convert thumbnail URLs to full image URLs +fun String.thumbnailToFull(): String { + val ext = substringAfterLast(".") + return replace("t.$ext", ".$ext") +} + +fun String?.toDate(simpleDateFormat: SimpleDateFormat?): Long { + this ?: return 0L + + return if (simpleDateFormat != null) { + if (contains(regexDateSuffix)) { + // Clean date (e.g. 5th December 2019 to 5 December 2019) before parsing it + split(" ").map { + if (it.contains(regexDate)) { + it.replace(regexNotNumber, "") + } else { + it + } + } + .let { simpleDateFormat.tryParse(it.joinToString(" ")) } + } else { + simpleDateFormat.tryParse(this) + } + } else { + parseDate(this) + } +} + +private fun parseDate(date: String?): Long { + date ?: return 0 + + return when { + // Handle 'yesterday' and 'today', using midnight + WordSet("yesterday", "يوم واحد").startsWith(date) -> { + Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -1) // yesterday + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + WordSet("today", "just now").startsWith(date) -> { + Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + WordSet("يومين").startsWith(date) -> { + Calendar.getInstance().apply { + add(Calendar.DAY_OF_MONTH, -2) // day before yesterday + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + }.timeInMillis + } + WordSet("ago", "atrás", "önce", "قبل").endsWith(date) -> { + parseRelativeDate(date) + } + WordSet("hace").startsWith(date) -> { + parseRelativeDate(date) + } + else -> 0L + } +} + +// Parses dates in this form: 21 hours ago OR "2 days ago (Updated 19 hours ago)" +private fun parseRelativeDate(date: String): Long { + val number = regexRelativeDateTime.find(date)?.value?.toIntOrNull() + ?: date.split(" ").firstOrNull() + ?.replace("one", "1") + ?.replace("a", "1") + ?.toIntOrNull() + ?: return 0L + val now = Calendar.getInstance() + + // Sort by order + return when { + WordSet("detik", "segundo", "second", "วินาที").anyWordIn(date) -> + now.apply { add(Calendar.SECOND, -number) }.timeInMillis + WordSet("menit", "dakika", "min", "minute", "minuto", "นาที", "دقائق").anyWordIn(date) -> + now.apply { add(Calendar.MINUTE, -number) }.timeInMillis + WordSet("jam", "saat", "heure", "hora", "hour", "ชั่วโมง", "giờ", "ore", "ساعة", "小时").anyWordIn(date) -> + now.apply { add(Calendar.HOUR, -number) }.timeInMillis + WordSet("hari", "gün", "jour", "día", "dia", "day", "วัน", "ngày", "giorni", "أيام", "天").anyWordIn(date) -> + now.apply { add(Calendar.DAY_OF_MONTH, -number) }.timeInMillis + WordSet("week", "semana").anyWordIn(date) -> + now.apply { add(Calendar.DAY_OF_MONTH, -number * 7) }.timeInMillis + WordSet("month", "mes").anyWordIn(date) -> + now.apply { add(Calendar.MONTH, -number) }.timeInMillis + WordSet("year", "año").anyWordIn(date) -> + now.apply { add(Calendar.YEAR, -number) }.timeInMillis + else -> 0L + } +} + +private fun SimpleDateFormat.tryParse(string: String): Long { + return try { + parse(string)?.time ?: 0L + } catch (_: ParseException) { + 0L + } +} + +class WordSet(private vararg val words: String) { + fun anyWordIn(dateString: String): Boolean = words.any { dateString.contains(it, ignoreCase = true) } + fun startsWith(dateString: String): Boolean = words.any { dateString.startsWith(it, ignoreCase = true) } + fun endsWith(dateString: String): Boolean = words.any { dateString.endsWith(it, ignoreCase = true) } +} + +fun toBinary(boolean: Boolean) = if (boolean) "1" else "0" diff --git a/src/all/asmhentai/build.gradle b/src/all/asmhentai/build.gradle index e73783778..26c5c5507 100644 --- a/src/all/asmhentai/build.gradle +++ b/src/all/asmhentai/build.gradle @@ -1,7 +1,9 @@ ext { extName = 'AsmHentai' extClass = '.ASMHFactory' - extVersionCode = 1 + themePkg = 'galleryadults' + baseUrl = 'https://asmhentai.com' + overrideVersionCode = 2 isNsfw = true } diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt index 41a2563d5..17aaeb430 100644 --- a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt +++ b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/ASMHFactory.kt @@ -1,13 +1,14 @@ package eu.kanade.tachiyomi.extension.all.asmhentai +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory class ASMHFactory : SourceFactory { override fun createSources(): List = listOf( - AsmHentai("en", "english"), - AsmHentai("ja", "japanese"), - AsmHentai("zh", "chinese"), - AsmHentai("all", ""), + AsmHentai("en", GalleryAdults.LANGUAGE_ENGLISH), + AsmHentai("ja", GalleryAdults.LANGUAGE_JAPANESE), + AsmHentai("zh", GalleryAdults.LANGUAGE_CHINESE), + AsmHentai("all", GalleryAdults.LANGUAGE_MULTI), ) } diff --git a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt index f470fca67..d03c4b239 100644 --- a/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt +++ b/src/all/asmhentai/src/eu/kanade/tachiyomi/extension/all/asmhentai/AsmHentai.kt @@ -1,274 +1,102 @@ package eu.kanade.tachiyomi.extension.all.asmhentai -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.POST -import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults +import eu.kanade.tachiyomi.multisrc.galleryadults.cleanTag +import eu.kanade.tachiyomi.multisrc.galleryadults.imgAttr 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.model.UpdateStrategy -import eu.kanade.tachiyomi.source.online.ParsedHttpSource -import eu.kanade.tachiyomi.util.asJsoup import okhttp3.FormBody -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response import org.jsoup.nodes.Document import org.jsoup.nodes.Element -import rx.Observable -open class AsmHentai(override val lang: String, private val tlTag: String) : ParsedHttpSource() { +class AsmHentai( + lang: String = "all", + override val mangaLang: String = LANGUAGE_MULTI, +) : GalleryAdults( + "AsmHentai", + "https://asmhentai.com", + lang = lang, +) { + override val supportsLatest = mangaLang.isNotBlank() - override val client: OkHttpClient = network.cloudflareClient + override fun Element.mangaUrl() = + selectFirst(".image a")?.attr("abs:href") - override val baseUrl = "https://asmhentai.com" + override fun Element.mangaThumbnail() = + selectFirst(".image img")?.imgAttr() - override val name = "AsmHentai" + override fun Element.mangaLang() = + select("a:has(.flag)").attr("href") + .removeSuffix("/").substringAfterLast("/") - override val supportsLatest = false + override fun popularMangaSelector() = ".preview_item" - // Popular - - override fun popularMangaRequest(page: Int): Request { - val url = baseUrl.toHttpUrl().newBuilder().apply { - if (tlTag.isNotEmpty()) addPathSegments("language/$tlTag/") - if (page > 1) addQueryParameter("page", page.toString()) - } - return GET(url.build(), headers) + override fun Element.getInfo(tag: String): String { + return select(".tags:contains($tag:) .tag") + .joinToString { it.ownText().cleanTag() } } - override fun popularMangaSelector(): String = ".preview_item" - - private fun Element.mangaTitle() = select("h2").text() - - private fun Element.mangaUrl() = select(".image a").attr("abs:href") - - private fun Element.mangaThumbnail() = select(".image img").attr("abs:src") - - override fun popularMangaFromElement(element: Element): SManga { - return SManga.create().apply { - title = element.mangaTitle() - setUrlWithoutDomain(element.mangaUrl()) - thumbnail_url = element.mangaThumbnail() - } + override fun Element.getDescription(): String { + return ( + listOf("Parodies", "Characters", "Languages", "Category") + .mapNotNull { tag -> + getInfo(tag) + .let { if (it.isNotBlank()) "$tag: $it" else null } + } + + listOfNotNull( + selectFirst(".book_page .pages h3")?.ownText(), + selectFirst(".book_page h1 + h2")?.ownText() + .let { altTitle -> if (!altTitle.isNullOrBlank()) "Alternate Title: $altTitle" else null }, + ) + ) + .joinToString("\n\n") + .plus( + if (preferences.shortTitle) { + "\nFull title: ${mangaFullTitle("h1")}" + } else { + "" + }, + ) } - override fun popularMangaNextPageSelector(): String = "li.active + li:not(.disabled)" + /* Search */ + override val favoritePath = "inc/user.php?act=favs" - // Latest + override val mangaDetailInfoSelector = ".book_page" - override fun latestUpdatesNextPageSelector(): String? { - throw UnsupportedOperationException() + override val galleryIdSelector = "load_id" + override val totalPagesSelector = "t_pages" + override val pageUri = "gallery" + override val pageSelector = ".preview_thumb" + + override fun pageRequestForm(document: Document, totalPages: String): FormBody { + val token = document.select("[name=csrf-token]").attr("content") + + return FormBody.Builder() + .add("_token", token) + .add("id", document.inputIdValueOf(loadIdSelector)) + .add("dir", document.inputIdValueOf(loadDirSelector)) + .add("visible_pages", "10") + .add("t_pages", totalPages) + .add("type", "2") // 1 would be "more", 2 is "all remaining" + .build() } - override fun latestUpdatesRequest(page: Int): Request { - throw UnsupportedOperationException() - } - - override fun latestUpdatesFromElement(element: Element): SManga { - throw UnsupportedOperationException() - } - - override fun latestUpdatesSelector(): String { - throw UnsupportedOperationException() - } - - // Search - - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - return when { - query.startsWith(PREFIX_ID_SEARCH) -> { - val id = query.removePrefix(PREFIX_ID_SEARCH) - client.newCall(searchMangaByIdRequest(id)) - .asObservableSuccess() - .map { response -> searchMangaByIdParse(response, id) } - } - query.toIntOrNull() != null -> { - client.newCall(searchMangaByIdRequest(query)) - .asObservableSuccess() - .map { response -> searchMangaByIdParse(response, query) } - } - else -> super.fetchSearchManga(page, query, filters) - } - } - - // any space except after a comma (we're going to replace spaces only between words) - private val spaceRegex = Regex("""(? query - query.isBlank() -> tags - else -> "$query,$tags" - }.replace(spaceRegex, "+") - - val url = baseUrl.toHttpUrl().newBuilder().apply { - addPathSegments("search/") - addEncodedQueryParameter("q", q) - if (page > 1) addQueryParameter("page", page.toString()) - } - - return GET(url.build(), headers) - } - - private class SMangaDto( - val title: String, - val url: String, - val thumbnail: String, - val lang: String, - ) - - override fun searchMangaParse(response: Response): MangasPage { - val doc = response.asJsoup() - - val mangas = doc.select(searchMangaSelector()) - .map { - SMangaDto( - title = it.mangaTitle(), - url = it.mangaUrl(), - thumbnail = it.mangaThumbnail(), - lang = it.select("a:has(.flag)").attr("href").removeSuffix("/").substringAfterLast("/"), + /* Filters */ + override fun tagsParser(document: Document): List> { + return document.select(".tags_page ul.tags li") + .mapNotNull { + Pair( + it.selectFirst("a.tag")?.ownText() ?: "", + it.select("a.tag").attr("href") + .removeSuffix("/").substringAfterLast('/'), ) } - .let { unfiltered -> - if (tlTag.isNotEmpty()) unfiltered.filter { it.lang == tlTag } else unfiltered - } - .map { - SManga.create().apply { - title = it.title - setUrlWithoutDomain(it.url) - thumbnail_url = it.thumbnail - } - } - - return MangasPage(mangas, doc.select(searchMangaNextPageSelector()).isNotEmpty()) } - private fun searchMangaByIdRequest(id: String) = GET("$baseUrl/g/$id/", headers) - - private fun searchMangaByIdParse(response: Response, id: String): MangasPage { - val details = mangaDetailsParse(response) - details.url = "/g/$id/" - return MangasPage(listOf(details), false) - } - - override fun searchMangaFromElement(element: Element) = popularMangaFromElement(element) - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - - // Details - - private fun Element.get(tag: String): String { - return select(".tags:contains($tag) .tag").joinToString { it.ownText() } - } - - override fun mangaDetailsParse(document: Document): SManga { - return SManga.create().apply { - update_strategy = UpdateStrategy.ONLY_FETCH_ONCE - document.select(".book_page").first()!!.let { element -> - thumbnail_url = element.select(".cover img").attr("abs:src") - title = element.select("h1").text() - genre = element.get("Tags") - artist = element.get("Artists") - author = artist - description = listOf("Parodies", "Groups", "Languages", "Category") - .mapNotNull { tag -> - element.get(tag).let { if (it.isNotEmpty()) "$tag: $it" else null } - } - .joinToString("\n", postfix = "\n") + - element.select(".pages h3").text() + - element.select("h1 + h2").text() - .let { altTitle -> if (altTitle.isNotEmpty()) "\nAlternate Title: $altTitle" else "" } - } - } - } - - // Chapters - - override fun fetchChapterList(manga: SManga): Observable> { - return Observable.just( - listOf( - SChapter.create().apply { - name = "Chapter" - url = manga.url - }, - ), - ) - } - - override fun chapterListSelector(): String { - throw UnsupportedOperationException() - } - - override fun chapterFromElement(element: Element): SChapter { - throw UnsupportedOperationException() - } - - // Pages - - // convert thumbnail URLs to full image URLs - private fun String.full(): String { - val fType = substringAfterLast("t") - return replace("t$fType", fType) - } - - private fun Document.inputIdValueOf(string: String): String { - return select("input[id=$string]").attr("value") - } - - override fun pageListParse(document: Document): List { - val thumbUrls = document.select(".preview_thumb img") - .map { it.attr("abs:data-src") } - .toMutableList() - - // input only exists if pages > 10 and have to make a request to get the other thumbnails - val totalPages = document.inputIdValueOf("t_pages") - - if (totalPages.isNotEmpty()) { - val token = document.select("[name=csrf-token]").attr("content") - - val form = FormBody.Builder() - .add("_token", token) - .add("id", document.inputIdValueOf("load_id")) - .add("dir", document.inputIdValueOf("load_dir")) - .add("visible_pages", "10") - .add("t_pages", totalPages) - .add("type", "2") // 1 would be "more", 2 is "all remaining" - .build() - - val xhrHeaders = headers.newBuilder() - .add("X-Requested-With", "XMLHttpRequest") - .build() - - client.newCall(POST("$baseUrl/inc/thumbs_loader.php", xhrHeaders, form)) - .execute() - .asJsoup() - .select("img") - .mapTo(thumbUrls) { it.attr("abs:data-src") } - } - return thumbUrls.mapIndexed { i, url -> Page(i, "", url.full()) } - } - - override fun imageUrlParse(document: Document) = throw UnsupportedOperationException() - - // Filters - - override fun getFilterList(): FilterList = FilterList( - Filter.Header("Separate tags with commas (,)"), - TagFilter(), + override fun getFilterList() = FilterList( + listOf( + Filter.Header("HINT: Separate search term with comma (,)"), + ) + super.getFilterList().list, ) - - class TagFilter : Filter.Text("Tags") - - companion object { - const val PREFIX_ID_SEARCH = "id:" - } } diff --git a/src/all/hentaifox/build.gradle b/src/all/hentaifox/build.gradle new file mode 100644 index 000000000..20ecdab90 --- /dev/null +++ b/src/all/hentaifox/build.gradle @@ -0,0 +1,10 @@ +ext { + extName = 'HentaiFox' + extClass = '.HentaiFoxFactory' + themePkg = 'galleryadults' + baseUrl = 'https://hentaifox.com' + overrideVersionCode = 6 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/hentaifox/res/mipmap-hdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-hdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-hdpi/ic_launcher.png diff --git a/src/en/hentaifox/res/mipmap-mdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-mdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-mdpi/ic_launcher.png diff --git a/src/en/hentaifox/res/mipmap-xhdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-xhdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-xhdpi/ic_launcher.png diff --git a/src/en/hentaifox/res/mipmap-xxhdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-xxhdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-xxhdpi/ic_launcher.png diff --git a/src/en/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from src/en/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png rename to src/all/hentaifox/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFox.kt b/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFox.kt new file mode 100644 index 000000000..698aad26f --- /dev/null +++ b/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFox.kt @@ -0,0 +1,97 @@ +package eu.kanade.tachiyomi.extension.all.hentaifox + +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults +import eu.kanade.tachiyomi.multisrc.galleryadults.toDate +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import kotlin.random.Random + +class HentaiFox( + lang: String = "all", + override val mangaLang: String = LANGUAGE_MULTI, +) : GalleryAdults( + "HentaiFox", + "https://hentaifox.com", + lang = lang, + mangaLang = mangaLang, + simpleDateFormat = null, +) { + override val supportsLatest = mangaLang.isNotBlank() + + private val languages: List> = listOf( + Pair(LANGUAGE_ENGLISH, "1"), + Pair(LANGUAGE_TRANSLATED, "2"), + Pair(LANGUAGE_JAPANESE, "5"), + Pair(LANGUAGE_CHINESE, "6"), + Pair(LANGUAGE_KOREAN, "11"), + ) + private val langCode = languages.firstOrNull { lang -> lang.first == mangaLang }?.second + + override fun Element.mangaLang() = attr("data-languages") + .split(' ').let { + when { + it.contains(langCode) -> mangaLang + // search result doesn't have "data-languages" which will return a list with 1 blank element + it.size > 1 || (it.size == 1 && it.first().isNotBlank()) -> "other" + // if we don't know which language to filter then set to mangaLang to not filter at all + else -> mangaLang + } + } + + override fun Element.mangaTitle(selector: String): String? = mangaFullTitle(selector) + + override fun Element.getTime(): Long { + return selectFirst(".pages:contains(Posted:)")?.ownText() + ?.removePrefix("Posted: ") + .toDate(simpleDateFormat) + } + + override fun HttpUrl.Builder.addPageUri(page: Int): HttpUrl.Builder { + val url = toString() + when { + url == "$baseUrl/" && page == 2 -> + addPathSegments("page/$page") + url.contains('?') -> + addQueryParameter("page", page.toString()) + page > 1 -> + addPathSegments("pag/$page") + } + addPathSegment("") // trailing slash (/) + return this + } + + /* Pages */ + override val pagesRequest = "includes/thumbs_loader.php" + + override fun getServer(document: Document, galleryId: String): String { + val domain = baseUrl.toHttpUrl().host + // Randomly choose between servers + return if (Random.nextBoolean()) "i2.$domain" else "i.$domain" + } + + /** + * Convert space( ) typed in search-box into plus(+) in URL. Then: + * - ignore the word preceding by a special character (e.g. 'school-girl' will ignore 'girl') + * => replace to plus(+), + * - use plus(+) for separate terms, as AND condition. + * - use double quote(") to search for exact match. + */ + override fun buildQueryString(tags: List, query: String): String { + val regexSpecialCharacters = Regex("""[^a-zA-Z0-9"]+(?=[a-zA-Z0-9"])""") + return (tags + query + mangaLang).filterNot { it.isBlank() }.joinToString("+") { + it.trim().replace(regexSpecialCharacters, "+") + } + } + + override fun getFilterList() = FilterList( + listOf( + Filter.Header("HINT: Use double quote (\") for exact match"), + ) + super.getFilterList().list, + ) + + override val idPrefixUri = "gallery" +} diff --git a/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFoxFactory.kt b/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFoxFactory.kt new file mode 100644 index 000000000..45c2621b2 --- /dev/null +++ b/src/all/hentaifox/src/eu/kanade/tachiyomi/extension/all/hentaifox/HentaiFoxFactory.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.extension.all.hentaifox + +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class HentaiFoxFactory : SourceFactory { + override fun createSources(): List = listOf( + HentaiFox("en", GalleryAdults.LANGUAGE_ENGLISH), + HentaiFox("ja", GalleryAdults.LANGUAGE_JAPANESE), + HentaiFox("zh", GalleryAdults.LANGUAGE_CHINESE), + HentaiFox("ko", GalleryAdults.LANGUAGE_KOREAN), + HentaiFox("all", GalleryAdults.LANGUAGE_MULTI), + ) +} diff --git a/src/all/imhentai/build.gradle b/src/all/imhentai/build.gradle index 8aba20a5a..bccb1a0b2 100644 --- a/src/all/imhentai/build.gradle +++ b/src/all/imhentai/build.gradle @@ -1,7 +1,9 @@ ext { extName = 'IMHentai' extClass = '.IMHentaiFactory' - extVersionCode = 14 + themePkg = 'galleryadults' + baseUrl = 'https://imhentai.xxx' + overrideVersionCode = 15 isNsfw = true } diff --git a/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt b/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt index 96cd5eb80..2e544da9c 100644 --- a/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt +++ b/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentai.kt @@ -1,33 +1,37 @@ package eu.kanade.tachiyomi.extension.all.imhentai -import eu.kanade.tachiyomi.network.GET -import eu.kanade.tachiyomi.network.asObservableSuccess -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 kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults +import eu.kanade.tachiyomi.multisrc.galleryadults.cleanTag +import eu.kanade.tachiyomi.multisrc.galleryadults.imgAttr +import okhttp3.FormBody import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient -import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import org.jsoup.nodes.Document import org.jsoup.nodes.Element -import org.jsoup.select.Elements -import rx.Observable -import uy.kohesive.injekt.injectLazy import java.io.IOException -class IMHentai(override val lang: String, private val imhLang: String) : ParsedHttpSource() { - - override val baseUrl: String = "https://imhentai.xxx" - override val name: String = "IMHentai" +class IMHentai( + lang: String = "all", + override val mangaLang: String = LANGUAGE_MULTI, +) : GalleryAdults( + "IMHentai", + "https://imhentai.xxx", + lang = lang, +) { override val supportsLatest = true + override val useIntermediateSearch: Boolean = true + override val supportAdvancedSearch: Boolean = true + override val supportSpeechless: Boolean = true + + override fun Element.mangaLang() = + select("a:has(.thumb_flag)").attr("href") + .removeSuffix("/").substringAfterLast("/") + .let { + // Include Speechless in search results + if (it == LANGUAGE_SPEECHLESS) mangaLang else it + } override val client: OkHttpClient = network.cloudflareClient .newBuilder() @@ -57,271 +61,103 @@ class IMHentai(override val lang: String, private val imhLang: String) : ParsedH }, ).build() - // Popular + override val favoritePath = "user/fav_pags.php" - override fun popularMangaFromElement(element: Element): SManga { - return SManga.create().apply { - thumbnail_url = element.selectFirst(".inner_thumb img")?.let { - it.absUrl(if (it.hasAttr("data-src")) "data-src" else "src") + /* Details */ + override fun Element.getInfo(tag: String): String { + return select("li:has(.tags_text:contains($tag:)) .tag").map { + it?.run { + listOf( + ownText().cleanTag(), + select(".split_tag").text() + .trim() + .removePrefix("| ") + .cleanTag(), + ) + .filter { s -> s.isNotBlank() } + .joinToString() } - with(element.select(".caption a")) { - url = this.attr("href") - title = this.text() - } - } + }.joinToString() } - override fun popularMangaNextPageSelector(): String = ".pagination li a:contains(Next):not([tabindex])" - - override fun popularMangaSelector(): String = ".thumbs_container .thumb" - - override fun popularMangaRequest(page: Int): Request = searchMangaRequest(page, "", getFilterList(SORT_ORDER_POPULAR)) - - // Latest - - override fun latestUpdatesFromElement(element: Element): SManga = popularMangaFromElement(element) - - override fun latestUpdatesNextPageSelector(): String = popularMangaNextPageSelector() - - override fun latestUpdatesRequest(page: Int): Request = searchMangaRequest(page, "", getFilterList(SORT_ORDER_LATEST)) - - override fun latestUpdatesSelector(): String = popularMangaSelector() - - // Search - - override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { - if (query.startsWith("id:")) { - val id = query.substringAfter("id:") - return client.newCall(GET("$baseUrl/gallery/$id/")) - .asObservableSuccess() - .map { response -> - val manga = mangaDetailsParse(response) - manga.url = "/gallery/$id/" - MangasPage(listOf(manga), false) - } - } - return super.fetchSearchManga(page, query, filters) - } - - override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) - - override fun searchMangaNextPageSelector(): String = popularMangaNextPageSelector() - - private fun toBinary(boolean: Boolean) = if (boolean) "1" else "0" - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - if (filters.any { it is LanguageFilters && it.state.any { it.name == LANGUAGE_SPEECHLESS && it.state } }) { // edge case for language = speechless - val url = "$baseUrl/language/speechless/".toHttpUrl().newBuilder() - - if ((if (filters.isEmpty()) getFilterList() else filters).filterIsInstance()[0].state == 0) { - url.addPathSegment("popular") - } - return GET(url.build()) - } else { - val url = "$baseUrl/search".toHttpUrl().newBuilder() - .addQueryParameter("key", query) - .addQueryParameter("page", page.toString()) - .addQueryParameter(getLanguageURIByName(imhLang).uri, toBinary(true)) // main language always enabled - - (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> - when (filter) { - is LanguageFilters -> { - filter.state.forEach { - url.addQueryParameter(it.uri, toBinary(it.state)) - } - } - is CategoryFilters -> { - filter.state.forEach { - url.addQueryParameter(it.uri, toBinary(it.state)) - } - } - is SortOrderFilter -> { - getSortOrderURIs().forEachIndexed { index, pair -> - url.addQueryParameter(pair.second, toBinary(filter.state == index)) - } - } - else -> {} - } - } - return GET(url.build()) - } - } - - override fun searchMangaSelector(): String = popularMangaSelector() - - // Details - - private fun Elements.csvText(splitTagSeparator: String = ", "): String { - return this.joinToString { - listOf( - it.ownText(), - it.select(".split_tag").text() - .trim() - .removePrefix("| "), + override fun Element.getDescription(): String { + return ( + listOf("Parodies", "Characters", "Languages", "Category") + .mapNotNull { tag -> + getInfo(tag) + .let { if (it.isNotBlank()) "$tag: $it" else null } + } + + listOfNotNull( + selectFirst(".pages")?.ownText(), + selectFirst(".subtitle")?.ownText() + .let { altTitle -> if (!altTitle.isNullOrBlank()) "Alternate Title: $altTitle" else null }, + ) ) - .filter { s -> !s.isNullOrBlank() } - .joinToString(splitTagSeparator) + .joinToString("\n\n") + .plus( + if (preferences.shortTitle) { + "\nFull title: ${mangaFullTitle("h1")}" + } else { + "" + }, + ) + } + + override fun Element.getCover() = + selectFirst(".left_cover img")?.imgAttr() + + override val mangaDetailInfoSelector = ".gallery_first" + + /* Pages */ + override val pageUri = "view" + override val pageSelector = ".gthumb" + private val serverSelector = "load_server" + + private fun serverNumber(document: Document, galleryId: String): String { + return document.inputIdValueOf(serverSelector).takeIf { + it.isNotBlank() + } ?: when (galleryId.toInt()) { + in 1..274825 -> "1" + in 274826..403818 -> "2" + in 403819..527143 -> "3" + in 527144..632481 -> "4" + in 632482..816010 -> "5" + in 816011..970098 -> "6" + in 970099..1121113 -> "7" + else -> "8" } } - override fun mangaDetailsParse(document: Document): SManga = SManga.create().apply { - title = document.selectFirst("div.right_details > h1")!!.text() - - thumbnail_url = document.selectFirst("div.left_cover img")?.let { - it.absUrl(if (it.hasAttr("data-src")) "data-src" else "src") - } - - val mangaInfoElement = document.select(".galleries_info") - val infoMap = mangaInfoElement.select("li:not(.pages)").associate { - it.select("span.tags_text").text().removeSuffix(":") to it.select(".tag") - } - - artist = infoMap["Artists"]?.csvText(" | ") - - author = artist - - genre = infoMap["Tags"]?.csvText() - - status = SManga.COMPLETED - - val pages = mangaInfoElement.select("li.pages").text().substringAfter("Pages: ") - val altTitle = document.select(".subtitle").text().ifBlank { null } - - description = listOf( - "Parodies", - "Characters", - "Groups", - "Languages", - "Category", - ).map { it to infoMap[it]?.csvText() } - .let { listOf(Pair("Alternate Title", altTitle)) + it + listOf(Pair("Pages", pages)) } - .filter { !it.second.isNullOrEmpty() } - .joinToString("\n\n") { "${it.first}:\n${it.second}" } + override fun getServer(document: Document, galleryId: String): String { + val domain = baseUrl.toHttpUrl().host + return "m${serverNumber(document, galleryId)}.$domain" } - // Chapters + override fun pageRequestForm(document: Document, totalPages: String): FormBody { + val galleryId = document.inputIdValueOf(galleryIdSelector) - override fun chapterListParse(response: Response): List { - return listOf( - SChapter.create().apply { - setUrlWithoutDomain(response.request.url.toString().replace("gallery", "view") + "1") - name = "Chapter" - chapter_number = 1f - }, - ) + return FormBody.Builder() + .add("server", serverNumber(document, galleryId)) + .add("u_id", document.inputIdValueOf(galleryIdSelector)) + .add("g_id", document.inputIdValueOf(loadIdSelector)) + .add("img_dir", document.inputIdValueOf(loadDirSelector)) + .add("visible_pages", "10") + .add("total_pages", totalPages) + .add("type", "2") // 1 would be "more", 2 is "all remaining" + .build() } - override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() - - override fun chapterListSelector(): String = throw UnsupportedOperationException() - - // Pages - - private val json: Json by injectLazy() - - override fun pageListParse(document: Document): List { - val imageDir = document.select("#image_dir").`val`() - val galleryId = document.select("#gallery_id").`val`() - val uId = document.select("#u_id").`val`().toInt() - - val randomServer = when (uId) { - in 1..274825 -> "m1.imhentai.xxx" - in 274826..403818 -> "m2.imhentai.xxx" - in 403819..527143 -> "m3.imhentai.xxx" - in 527144..632481 -> "m4.imhentai.xxx" - in 632482..816010 -> "m5.imhentai.xxx" - in 816011..970098 -> "m6.imhentai.xxx" - in 970099..1121113 -> "m7.imhentai.xxx" - else -> "m8.imhentai.xxx" - } - - val images = json.parseToJsonElement( - document.selectFirst("script:containsData(var g_th)")!!.data() - .substringAfter("$.parseJSON('").substringBefore("');").trim(), - ).jsonObject - val pages = mutableListOf() - - for (image in images) { - val iext = image.value.toString().replace("\"", "").split(",")[0] - val iextPr = when (iext) { - "p" -> "png" - "b" -> "bmp" - "g" -> "gif" - else -> "jpg" + /* Filters */ + override fun tagsParser(document: Document): List> { + return document.select(".stags .tag_btn") + .mapNotNull { + Pair( + it.selectFirst(".list_tag")?.ownText() ?: "", + it.select("a").attr("href") + .removeSuffix("/").substringAfterLast('/'), + ) } - pages.add(Page(image.key.toInt() - 1, "", "https://$randomServer/$imageDir/$galleryId/${image.key}.$iextPr")) - } - return pages } - override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException() - - // Filters - - private class SortOrderFilter(sortOrderURIs: List>, state: Int) : - Filter.Select("Sort By", sortOrderURIs.map { it.first }.toTypedArray(), state) - - private open class SearchFlagFilter(name: String, val uri: String, state: Boolean = true) : Filter.CheckBox(name, state) - private class LanguageFilter(name: String, uri: String = name) : SearchFlagFilter(name, uri, false) - private class LanguageFilters(flags: List) : Filter.Group("Other Languages", flags) - private class CategoryFilters(flags: List) : Filter.Group("Categories", flags) - - override fun getFilterList() = getFilterList(SORT_ORDER_DEFAULT) - - private fun getFilterList(sortOrderState: Int) = FilterList( - SortOrderFilter(getSortOrderURIs(), sortOrderState), - CategoryFilters(getCategoryURIs()), - LanguageFilters(getLanguageURIs().filter { it.name != imhLang }), // exclude main lang - Filter.Header("Speechless language: ignores all filters except \"Popular\" and \"Latest\" in Sorting Filter"), - ) - - private fun getCategoryURIs() = listOf( - SearchFlagFilter("Manga", "manga"), - SearchFlagFilter("Doujinshi", "doujinshi"), - SearchFlagFilter("Western", "western"), - SearchFlagFilter("Image Set", "imageset"), - SearchFlagFilter("Artist CG", "artistcg"), - SearchFlagFilter("Game CG", "gamecg"), - ) - - // update sort order indices in companion object if order is changed - private fun getSortOrderURIs() = listOf( - Pair("Popular", "pp"), - Pair("Latest", "lt"), - Pair("Downloads", "dl"), - Pair("Top Rated", "tr"), - ) - - private fun getLanguageURIs() = listOf( - LanguageFilter(LANGUAGE_ENGLISH, "en"), - LanguageFilter(LANGUAGE_JAPANESE, "jp"), - LanguageFilter(LANGUAGE_SPANISH, "es"), - LanguageFilter(LANGUAGE_FRENCH, "fr"), - LanguageFilter(LANGUAGE_KOREAN, "kr"), - LanguageFilter(LANGUAGE_GERMAN, "de"), - LanguageFilter(LANGUAGE_RUSSIAN, "ru"), - LanguageFilter(LANGUAGE_SPEECHLESS, ""), - ) - - private fun getLanguageURIByName(name: String): LanguageFilter { - return getLanguageURIs().first { it.name == name } - } - - companion object { - - // references to sort order indices - private const val SORT_ORDER_POPULAR = 0 - private const val SORT_ORDER_LATEST = 1 - private const val SORT_ORDER_DEFAULT = SORT_ORDER_POPULAR - - // references to be used in factory - const val LANGUAGE_ENGLISH = "English" - const val LANGUAGE_JAPANESE = "Japanese" - const val LANGUAGE_SPANISH = "Spanish" - const val LANGUAGE_FRENCH = "French" - const val LANGUAGE_KOREAN = "Korean" - const val LANGUAGE_GERMAN = "German" - const val LANGUAGE_RUSSIAN = "Russian" - const val LANGUAGE_SPEECHLESS = "Speechless" - } + override val idPrefixUri = "gallery" } diff --git a/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentaiFactory.kt b/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentaiFactory.kt index b6b7add53..a3aa72078 100644 --- a/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentaiFactory.kt +++ b/src/all/imhentai/src/eu/kanade/tachiyomi/extension/all/imhentai/IMHentaiFactory.kt @@ -1,17 +1,19 @@ package eu.kanade.tachiyomi.extension.all.imhentai +import eu.kanade.tachiyomi.multisrc.galleryadults.GalleryAdults import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceFactory class IMHentaiFactory : SourceFactory { override fun createSources(): List = listOf( - IMHentai("en", IMHentai.LANGUAGE_ENGLISH), - IMHentai("ja", IMHentai.LANGUAGE_JAPANESE), - IMHentai("es", IMHentai.LANGUAGE_SPANISH), - IMHentai("fr", IMHentai.LANGUAGE_FRENCH), - IMHentai("ko", IMHentai.LANGUAGE_KOREAN), - IMHentai("de", IMHentai.LANGUAGE_GERMAN), - IMHentai("ru", IMHentai.LANGUAGE_RUSSIAN), + IMHentai("en", GalleryAdults.LANGUAGE_ENGLISH), + IMHentai("ja", GalleryAdults.LANGUAGE_JAPANESE), + IMHentai("es", GalleryAdults.LANGUAGE_SPANISH), + IMHentai("fr", GalleryAdults.LANGUAGE_FRENCH), + IMHentai("ko", GalleryAdults.LANGUAGE_KOREAN), + IMHentai("de", GalleryAdults.LANGUAGE_GERMAN), + IMHentai("ru", GalleryAdults.LANGUAGE_RUSSIAN), + IMHentai("all", GalleryAdults.LANGUAGE_MULTI), ) } diff --git a/src/en/hentaifox/build.gradle b/src/en/hentaifox/build.gradle deleted file mode 100644 index 56d8b1706..000000000 --- a/src/en/hentaifox/build.gradle +++ /dev/null @@ -1,8 +0,0 @@ -ext { - extName = 'HentaiFox' - extClass = '.HentaiFox' - extVersionCode = 5 - isNsfw = true -} - -apply from: "$rootDir/common.gradle" diff --git a/src/en/hentaifox/src/eu/kanade/tachiyomi/extension/en/hentaifox/HentaiFox.kt b/src/en/hentaifox/src/eu/kanade/tachiyomi/extension/en/hentaifox/HentaiFox.kt deleted file mode 100644 index 4301bd986..000000000 --- a/src/en/hentaifox/src/eu/kanade/tachiyomi/extension/en/hentaifox/HentaiFox.kt +++ /dev/null @@ -1,223 +0,0 @@ -package eu.kanade.tachiyomi.extension.en.hentaifox - -import eu.kanade.tachiyomi.network.GET -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 okhttp3.OkHttpClient -import okhttp3.Request -import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element -import rx.Observable - -class HentaiFox : ParsedHttpSource() { - - override val name = "HentaiFox" - - override val baseUrl = "https://hentaifox.com" - - override val lang = "en" - - override val supportsLatest = false - - override val client: OkHttpClient = network.cloudflareClient - - // Popular - - override fun popularMangaRequest(page: Int): Request { - return if (page == 2) { - GET("$baseUrl/page/$page/", headers) - } else { - GET("$baseUrl/pag/$page/", headers) - } - } - - override fun popularMangaSelector() = "div.thumb" - - override fun popularMangaFromElement(element: Element): SManga { - return SManga.create().apply { - element.select("h2 a").let { - title = it.text() - setUrlWithoutDomain(it.attr("href")) - } - thumbnail_url = element.selectFirst("img")!!.imgAttr() - } - } - - override fun popularMangaNextPageSelector() = "li.page-item:last-of-type:not(.disabled)" - - // Latest - - override fun latestUpdatesRequest(page: Int): Request = throw UnsupportedOperationException() - - override fun latestUpdatesParse(response: Response): MangasPage = throw UnsupportedOperationException() - - override fun latestUpdatesSelector() = throw UnsupportedOperationException() - - override fun latestUpdatesFromElement(element: Element): SManga = throw UnsupportedOperationException() - - override fun latestUpdatesNextPageSelector() = throw UnsupportedOperationException() - - // Search - - override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - return if (query.isNotEmpty()) { - GET("$baseUrl/search/?q=$query&page=$page", headers) - } else { - var url = "$baseUrl/tag/" - - filters.forEach { filter -> - when (filter) { - is GenreFilter -> { - url += "${filter.toUriPart()}/pag/$page/" - } - else -> {} - } - } - GET(url, headers) - } - } - - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaFromElement(element: Element): SManga = popularMangaFromElement(element) - - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - - // Details - - override fun mangaDetailsParse(document: Document): SManga { - return document.select("div.gallery_top").let { info -> - SManga.create().apply { - title = info.select("h1").text() - genre = info.select("ul.tags a").joinToString { it.ownText() } - artist = info.select("ul.artists a").joinToString { it.ownText() } - thumbnail_url = info.select("img").first()!!.imgAttr() - description = info.select("ul.parodies a") - .let { e -> if (e.isNotEmpty()) "Parodies: ${e.joinToString { it.ownText() }}\n\n" else "" } - description += info.select("ul.characters a") - .let { e -> if (e.isNotEmpty()) "Characters: ${e.joinToString { it.ownText() }}\n\n" else "" } - description += info.select("ul.groups a") - .let { e -> if (e.isNotEmpty()) "Groups: ${e.joinToString { it.ownText() }}\n\n" else "" } - } - } - } - - // Chapters - - override fun chapterListParse(response: Response): List { - return listOf( - SChapter.create().apply { - name = "Chapter" - // page path with a marker at the end - url = "${response.request.url.toString().replace("/gallery/", "/g/")}#" - // number of pages - url += response.asJsoup().select("[id=load_pages]").attr("value") - }, - ) - } - - override fun chapterListSelector() = throw UnsupportedOperationException() - - override fun chapterFromElement(element: Element): SChapter = throw UnsupportedOperationException() - - // Pages - - override fun fetchPageList(chapter: SChapter): Observable> { - // split the "url" to get the page path and number of pages - return chapter.url.split("#").let { list -> - Observable.just(listOf(1..list[1].toInt()).flatten().map { Page(it, list[0] + "$it/") }) - } - } - - override fun imageUrlParse(document: Document): String { - return document.selectFirst("img#gimg")!!.imgAttr() - } - - override fun pageListParse(document: Document): List = throw UnsupportedOperationException() - - // Filters - - override fun getFilterList() = FilterList( - Filter.Header("NOTE: Ignored if using text search!"), - Filter.Separator(), - GenreFilter(), - ) - - // Top 50 tags - private class GenreFilter : UriPartFilter( - "Category", - arrayOf( - Pair("