From 7929a768c4cb65536c852e81daf2e52432247700 Mon Sep 17 00:00:00 2001 From: Andy Bao <contactnulldev@gmail.com> Date: Sat, 20 May 2017 06:11:33 -0400 Subject: [PATCH] Add MangaPark, Mangago, Tapastic, nhentai, E-Hentai and Sen Manga sources (#36) * Add MangaPark source * Add pagination to MangaPark source Enable HTTPS in MangaPark source * Add Mangago source * Add Tapastic source Fix UriSelectFilters returning incorrect default states * Add nhentai source * Fix tapastic source breaking on manga with square brackets in title * Fix issues found by j2ghz Fix tapastic source showing scheduled comics * Add E-Hentai source Bump Kotlin version for all extensions to 1.1.1 Minor code cleanup in nhentai source * Add Sen Manga source. Minor code cleanup. * Fix incorrect package name in Sen Manga source. * Fix incorrect Japanese language code on E-Hentai, nhentai and Sen Manga sources. * Bump Kotlin version to 1.1.2 * Code cleanup Fix a bug with thumbnails and URLs in E-Hentai that is currently not triggerable but may cause problems in the future * Code cleanup * Fix some minor incorrect spacing --- build.gradle | 2 +- src/all/ehentai/build.gradle | 18 + .../extension/all/ehentai/EHLangs.kt | 42 ++ .../tachiyomi/extension/all/ehentai/EHUtil.kt | 67 ++++ .../extension/all/ehentai/EHentai.kt | 378 ++++++++++++++++++ .../all/ehentai/ExGalleryMetadata.kt | 31 ++ .../extension/all/ehentai/MetadataCopier.kt | 83 ++++ .../tachiyomi/extension/all/ehentai/Tag.kt | 7 + .../extension/all/ehentai/UriFilter.kt | 10 + .../extension/all/ehentai/UriGroup.kt | 15 + src/all/nhentai/build.gradle | 18 + .../extension/all/nhentai/MetadataCopier.kt | 72 ++++ .../extension/all/nhentai/NHLangs.kt | 29 ++ .../tachiyomi/extension/all/nhentai/NHUtil.kt | 17 + .../extension/all/nhentai/NHentai.kt | 216 ++++++++++ .../extension/all/nhentai/NHentaiMetadata.kt | 44 ++ .../tachiyomi/extension/all/nhentai/Tag.kt | 7 + src/en/mangago/build.gradle | 13 + .../tachiyomi/extension/en/mangago/Mangago.kt | 245 ++++++++++++ src/en/mangapark/build.gradle | 13 + .../extension/en/mangapark/MangaPark.kt | 291 ++++++++++++++ src/en/tapastic/build.gradle | 18 + .../extension/en/tapastic/Tapastic.kt | 212 ++++++++++ src/ja/senmanga/build.gradle | 13 + .../extension/ja/senmanga/SenManga.kt | 327 +++++++++++++++ 25 files changed, 2187 insertions(+), 1 deletion(-) create mode 100644 src/all/ehentai/build.gradle create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHLangs.kt create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHUtil.kt create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHentai.kt create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/ExGalleryMetadata.kt create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/MetadataCopier.kt create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/Tag.kt create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriFilter.kt create mode 100644 src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriGroup.kt create mode 100644 src/all/nhentai/build.gradle create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/MetadataCopier.kt create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHLangs.kt create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHUtil.kt create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentai.kt create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentaiMetadata.kt create mode 100644 src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/Tag.kt create mode 100644 src/en/mangago/build.gradle create mode 100644 src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt create mode 100644 src/en/mangapark/build.gradle create mode 100644 src/en/mangapark/src/eu/kanade/tachiyomi/extension/en/mangapark/MangaPark.kt create mode 100644 src/en/tapastic/build.gradle create mode 100644 src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt create mode 100644 src/ja/senmanga/build.gradle create mode 100644 src/ja/senmanga/src/eu/kanade/tachiyomi/extension/ja/senmanga/SenManga.kt diff --git a/build.gradle b/build.gradle index 685bd54e2..4d8c7944d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.0.6' + ext.kotlin_version = '1.1.2' repositories { jcenter() } diff --git a/src/all/ehentai/build.gradle b/src/all/ehentai/build.gradle new file mode 100644 index 000000000..b41e0f84e --- /dev/null +++ b/src/all/ehentai/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: E-Hentai' + pkgNameSuffix = "all.ehentai" + extClass = '.EHJapanese; .EHEnglish; .EHChinese; .EHDutch; .EHFrench; .EHGerman; .EHHungarian; .EHItalian; .EHKorean; .EHPolish; .EHPolish; .EHPortuguese; .EHRussian; .EHSpanish; .EHThai; .EHVietnamese; .EHSpeechless; .EHOther' + extVersionCode = 1 + extVersionSuffix = 1 + libVersion = '1.0' +} + +dependencies { + provided "com.google.code.gson:gson:2.8.0" + provided "com.github.salomonbrys.kotson:kotson:2.5.0" +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHLangs.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHLangs.kt new file mode 100644 index 000000000..ab662ff4c --- /dev/null +++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHLangs.kt @@ -0,0 +1,42 @@ +package eu.kanade.tachiyomi.extension.all.ehentai + +/** + * E-Hentai languages + */ +class EHJapanese: EHentai("ja", "japanese") +class EHEnglish: EHentai("en", "english") +class EHChinese: EHentai("zh", "chinese") +class EHDutch: EHentai("nl", "dutch") +class EHFrench: EHentai("fr", "french") +class EHGerman: EHentai("de", "german") +class EHHungarian: EHentai("hu", "hungarian") +class EHItalian: EHentai("it", "italian") +class EHKorean: EHentai("ko", "korean") +class EHPolish: EHentai("pl", "polish") +class EHPortuguese: EHentai("pt", "portuguese") +class EHRussian: EHentai("ru", "russian") +class EHSpanish: EHentai("es", "spanish") +class EHThai: EHentai("th", "thai") +class EHVietnamese: EHentai("vi", "vietnamese") +class EHSpeechless: EHentai("none", "n/a") +class EHOther: EHentai("other", "other") + +fun getAllEHentaiLanguages() = listOf( + EHJapanese(), + EHEnglish(), + EHChinese(), + EHDutch(), + EHFrench(), + EHGerman(), + EHHungarian(), + EHItalian(), + EHKorean(), + EHPolish(), + EHPortuguese(), + EHRussian(), + EHSpanish(), + EHThai(), + EHVietnamese(), + EHSpeechless(), + EHOther() +) diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHUtil.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHUtil.kt new file mode 100644 index 000000000..794113b8f --- /dev/null +++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHUtil.kt @@ -0,0 +1,67 @@ +package eu.kanade.tachiyomi.extension.all.ehentai + +/** + * Various utility methods used in the E-Hentai source + */ + +/** + * Return null if String is blank, otherwise returns the original String + * @returns null if the String is blank, otherwise returns the original String + */ +fun String?.nullIfBlank(): String? = if (isNullOrBlank()) + null +else + this + +/** + * Ignores any exceptions thrown inside a block + */ +fun <T> ignore(expr: () -> T): T? { + return try { + expr() + } catch (t: Throwable) { + null + } +} + +/** + * Use '+' to append Strings onto a StringBuilder + */ +operator fun StringBuilder.plusAssign(other: String) { + append(other) +} + +/** + * Converts bytes into a human readable String + */ +fun humanReadableByteCount(bytes: Long, si: Boolean): String { + val unit = if (si) 1000 else 1024 + if (bytes < unit) return bytes.toString() + " B" + val exp = (Math.log(bytes.toDouble()) / Math.log(unit.toDouble())).toInt() + val pre = (if (si) "kMGTPE" else "KMGTPE")[exp - 1] + if (si) "" else "i" + return String.format("%.1f %sB", bytes / Math.pow(unit.toDouble(), exp.toDouble()), pre) +} + +private val KB_FACTOR = 1000 +private val KIB_FACTOR = 1024 +private val MB_FACTOR = 1000 * KB_FACTOR +private val MIB_FACTOR = 1024 * KIB_FACTOR +private val GB_FACTOR = 1000 * MB_FACTOR +private val GIB_FACTOR = 1024 * MIB_FACTOR + +/** + * Parse human readable size Strings + */ +fun parseHumanReadableByteCount(arg0: String): Double? { + val spaceNdx = arg0.indexOf(" ") + val ret = arg0.substring(0 until spaceNdx).toDouble() + when (arg0.substring(spaceNdx + 1)) { + "GB" -> return ret * GB_FACTOR + "GiB" -> return ret * GIB_FACTOR + "MB" -> return ret * MB_FACTOR + "MiB" -> return ret * MIB_FACTOR + "KB" -> return ret * KB_FACTOR + "KiB" -> return ret * KIB_FACTOR + } + return null +} \ No newline at end of file diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHentai.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHentai.kt new file mode 100644 index 000000000..c287a3ffb --- /dev/null +++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/EHentai.kt @@ -0,0 +1,378 @@ +package eu.kanade.tachiyomi.extension.all.ehentai + +import android.net.Uri +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.CacheControl +import okhttp3.Headers +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import rx.Observable +import java.net.URLEncoder + +open class EHentai(override val lang: String, val ehLang: String) : HttpSource() { + + override val name = "E-Hentai" + + override val baseUrl = "https://e-hentai.org" + + override val supportsLatest = true + + /** + * Gallery list entry + * @param fav The favorite this gallery belongs to (currently unused) + * @param manga The manga object + */ + data class ParsedManga(val fav: String?, val manga: SManga) + + fun extendedGenericMangaParse(doc: Document) + = with(doc) { + //Parse mangas + val parsedMangas = select(".gtr0,.gtr1").map { + ParsedManga( + fav = it.select(".itd .it3 > .i[id]").attr("title"), + manga = SManga.create().apply { + //Get title + it.select(".itd .it5 a").apply { + title = text() + setUrlWithoutDomain(addParam(attr("href"), "nw", "always")) + } + //Get image + it.select(".itd .it2").first().apply { + children().first()?.let { + thumbnail_url = it.attr("src") + } ?: text().split("~").apply { + thumbnail_url = "http://${this[1]}/${this[2]}" + } + } + }) + + } + //Add to page if required + val hasNextPage = select("a[onclick=return false]").last()?.text() == ">" + Pair(parsedMangas, hasNextPage) + } + + /** + * Parse a list of galleries + */ + fun genericMangaParse(response: Response) + = extendedGenericMangaParse(response.asJsoup()).let { + MangasPage(it.first.map { it.manga }, it.second) + } + + override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> + = Observable.just(listOf(SChapter.create().apply { + url = manga.url + name = "Chapter" + chapter_number = 1f + })) + + override fun fetchPageList(chapter: SChapter) + = fetchChapterPage(chapter, "$baseUrl/${chapter.url}").map { + it.mapIndexed { i, s -> + Page(i, s) + } + }!! + + /** + * Recursively fetch chapter pages + */ + private fun fetchChapterPage(chapter: SChapter, np: String, + pastUrls: List<String> = emptyList()): Observable<List<String>> { + val urls = pastUrls.toMutableList() + return chapterPageCall(np).flatMap { + val jsoup = it.asJsoup() + urls += parseChapterPage(jsoup) + nextPageUrl(jsoup)?.let { + fetchChapterPage(chapter, it, urls) + } ?: Observable.just(urls) + } + } + + private fun parseChapterPage(response: Element) + = with(response) { + select(".gdtm a").map { + Pair(it.child(0).attr("alt").toInt(), it.attr("href")) + }.sortedBy(Pair<Int, String>::first).map { it.second } + } + + private fun chapterPageCall(np: String) = client.newCall(chapterPageRequest(np)).asObservableSuccess() + private fun chapterPageRequest(np: String) = exGet(np, null, headers) + + private fun nextPageUrl(element: Element) + = element.select("a[onclick=return false]").last()?.let { + if (it.text() == ">") it.attr("href") else null + } + + override fun popularMangaRequest(page: Int) = latestUpdatesRequest(page) + //This source supports finding popular manga but will not respect language filters on the popular manga page! + //We currently display the latest updates instead until this is fixed + //override fun popularMangaRequest(page: Int) = exGet("$baseUrl/toplist.php?tl=15", page) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val uri = Uri.parse("$baseUrl$QUERY_PREFIX").buildUpon() + uri.appendQueryParameter("f_search", query) + filters.forEach { + if (it is UriFilter) it.addToUri(uri) + } + return exGet(uri.toString(), page) + } + + override fun latestUpdatesRequest(page: Int) = exGet(baseUrl, page) + + override fun popularMangaParse(response: Response) = genericMangaParse(response) + override fun searchMangaParse(response: Response) = genericMangaParse(response) + override fun latestUpdatesParse(response: Response) = genericMangaParse(response) + + fun exGet(url: String, page: Int? = null, additionalHeaders: Headers? = null, cache: Boolean = true) + = GET(page?.let { + addParam(url, "page", (it - 1).toString()) + } ?: url, additionalHeaders?.let { + val headers = headers.newBuilder() + it.toMultimap().forEach { (t, u) -> + u.forEach { + headers.add(t, it) + } + } + headers.build() + } ?: headers).let { + if (!cache) + it.newBuilder().cacheControl(CacheControl.FORCE_NETWORK).build() + else + it + }!! + + /** + * Parse gallery page to metadata model + */ + override fun mangaDetailsParse(response: Response) = with(response.asJsoup()) { + with(ExGalleryMetadata()) { + url = response.request().url().encodedPath() + title = select("#gn").text().nullIfBlank()?.trim() + + altTitle = select("#gj").text().nullIfBlank()?.trim() + + //Thumbnail is set as background of element in style attribute + thumbnailUrl = select("#gd1 div").attr("style").nullIfBlank()?.let { + it.substring(it.indexOf('(') + 1 until it.lastIndexOf(')')) + } + + genre = select(".ic").parents().attr("href").nullIfBlank()?.trim()?.substringAfterLast('/') + + uploader = select("#gdn").text().nullIfBlank()?.trim() + + //Parse the table + select("#gdd tr").forEach { + val left = it.select(".gdt1").text().nullIfBlank()?.trim() ?: return@forEach + val right = it.select(".gdt2").text().nullIfBlank()?.trim() ?: return@forEach + ignore { + when (left.removeSuffix(":") + .toLowerCase()) { + "posted" -> datePosted = EX_DATE_FORMAT.parse(right).time + "visible" -> visible = right.nullIfBlank() + "language" -> { + language = right.removeSuffix(TR_SUFFIX).trim().nullIfBlank() + translated = right.endsWith(TR_SUFFIX, true) + } + "file size" -> size = parseHumanReadableByteCount(right)?.toLong() + "length" -> length = right.removeSuffix("pages").trim().nullIfBlank()?.toInt() + "favorited" -> favorites = right.removeSuffix("times").trim().nullIfBlank()?.toInt() + } + } + } + + //Parse ratings + ignore { + averageRating = getElementById("rating_label") + .text() + .removePrefix("Average:") + .trim() + .nullIfBlank() + ?.toDouble() + ratingCount = getElementById("rating_count") + .text() + .trim() + .nullIfBlank() + ?.toInt() + } + + //Parse tags + tags.clear() + select("#taglist tr").forEach { + val namespace = it.select(".tc").text().removeSuffix(":") + val currentTags = it.select("div").map { + Tag(it.text().trim(), + it.hasClass("gtl")) + } + tags.put(namespace, currentTags) + } + + //Copy metadata to manga + SManga.create().apply { + copyTo(this) + } + } + } + + override fun chapterListParse(response: Response) + = throw UnsupportedOperationException("Unused method was called somehow!") + + override fun pageListParse(response: Response) + = throw UnsupportedOperationException("Unused method was called somehow!") + + override fun fetchImageUrl(page: Page) + = client.newCall(imageUrlRequest(page)) + .asObservableSuccess() + .map { realImageUrlParse(it, page) }!! + + fun realImageUrlParse(response: Response, page: Page) + = with(response.asJsoup()) { + val currentImage = getElementById("img").attr("src") + //TODO We cannot currently do this as page.url is immutable + //Each press of the retry button will choose another server + /*select("#loadfail").attr("onclick").nullIfBlank()?.let { + page.url = addParam(page.url, "nl", it.substring(it.indexOf('\'') + 1 .. it.lastIndexOf('\'') - 1)) + }*/ + currentImage + }!! + + override fun imageUrlParse(response: Response) + = throw UnsupportedOperationException("Unused method was called somehow!") + + val cookiesHeader by lazy { + val cookies = mutableMapOf<String, String>() + + //Setup settings + val settings = mutableListOf<String>() + + //Do not show popular right now pane as we can't parse it + settings += "prn_n" + + //Exclude every other language except the one we have selected + settings += "xl_" + languageMappings.filter { it.first != ehLang } + .flatMap { it.second } + .joinToString("x") + + cookies.put("uconfig", buildSettings(settings)) + + buildCookies(cookies) + } + + //Headers + override fun headersBuilder() + = super.headersBuilder().add("Cookie", cookiesHeader)!! + + fun buildSettings(settings: List<String?>) + = settings.filterNotNull().joinToString(separator = "-") + + fun buildCookies(cookies: Map<String, String>) + = cookies.entries.map { + "${URLEncoder.encode(it.key, "UTF-8")}=${URLEncoder.encode(it.value, "UTF-8")}" + }.joinToString(separator = "; ", postfix = ";") + + fun addParam(url: String, param: String, value: String) + = Uri.parse(url) + .buildUpon() + .appendQueryParameter(param, value) + .toString() + + override val client = network.client.newBuilder() + .addNetworkInterceptor { chain -> + val newReq = chain + .request() + .newBuilder() + .addHeader("Cookie", cookiesHeader) + .build() + + chain.proceed(newReq) + }.build()!! + + //Filters + override fun getFilterList() = FilterList( + GenreGroup(), + AdvancedGroup() + ) + + class GenreOption(name: String, val genreId: String) : Filter.CheckBox(name, false), UriFilter { + override fun addToUri(builder: Uri.Builder) { + builder.appendQueryParameter("f_" + genreId, if (state) "1" else "0") + } + } + + class GenreGroup : UriGroup<GenreOption>("Genres", listOf( + GenreOption("Dōjinshi", "doujinshi"), + GenreOption("Manga", "manga"), + GenreOption("Artist CG", "artistcg"), + GenreOption("Game CG", "gamecg"), + GenreOption("Western", "western"), + GenreOption("Non-H", "non-h"), + GenreOption("Image Set", "imageset"), + GenreOption("Cosplay", "cosplay"), + GenreOption("Asian Porn", "asianporn"), + GenreOption("Misc", "misc") + )) + + class AdvancedOption(name: String, val param: String, defValue: Boolean = false) : Filter.CheckBox(name, defValue), UriFilter { + override fun addToUri(builder: Uri.Builder) { + if (state) + builder.appendQueryParameter(param, "on") + } + } + + class RatingOption : Filter.Select<String>("Minimum Rating", arrayOf( + "Any", + "2 stars", + "3 stars", + "4 stars", + "5 stars" + )), UriFilter { + override fun addToUri(builder: Uri.Builder) { + if (state > 0) builder.appendQueryParameter("f_srdd", Integer.toString(state + 1)) + } + } + + //Explicit type arg for listOf() to workaround this: KT-16570 + class AdvancedGroup : UriGroup<Filter<*>>("Advanced Options", listOf<Filter<*>>( + AdvancedOption("Search Gallery Name", "f_sname", true), + AdvancedOption("Search Gallery Tags", "f_stags", true), + AdvancedOption("Search Gallery Description", "f_sdesc"), + AdvancedOption("Search Torrent Filenames", "f_storr"), + AdvancedOption("Only Show Galleries With Torrents", "f_sto"), + AdvancedOption("Search Low-Power Tags", "f_sdt1"), + AdvancedOption("Search Downvoted Tags", "f_sdt2"), + AdvancedOption("Show Expunged Galleries", "f_sh"), + RatingOption() + )) + + //map languages to their internal ids + val languageMappings = listOf( + Pair("japanese", listOf("0", "1024", "2048")), + Pair("english", listOf("1", "1025", "2049")), + Pair("chinese", listOf("10", "1034", "2058")), + Pair("dutch", listOf("20", "1044", "2068")), + Pair("french", listOf("30", "1054", "2078")), + Pair("german", listOf("40", "1064", "2088")), + Pair("hungarian", listOf("50", "1074", "2098")), + Pair("italian", listOf("60", "1084", "2108")), + Pair("korean", listOf("70", "1094", "2118")), + Pair("polish", listOf("80", "1104", "2128")), + Pair("portuguese", listOf("90", "1114", "2138")), + Pair("russian", listOf("100", "1124", "2148")), + Pair("spanish", listOf("110", "1134", "2158")), + Pair("thai", listOf("120", "1144", "2168")), + Pair("vietnamese", listOf("130", "1154", "2178")), + Pair("n/a", listOf("254", "1278", "2302")), + Pair("other", listOf("255", "1279", "2303")) + ) + + companion object { + const val QUERY_PREFIX = "?f_apply=Apply+Filter" + const val TR_SUFFIX = "TR" + } +} \ No newline at end of file diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/ExGalleryMetadata.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/ExGalleryMetadata.kt new file mode 100644 index 000000000..09f3c2451 --- /dev/null +++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/ExGalleryMetadata.kt @@ -0,0 +1,31 @@ +package eu.kanade.tachiyomi.extension.all.ehentai; + +/** + * Gallery metadata storage model + */ + +class ExGalleryMetadata { + var url: String? = null + + var thumbnailUrl: String? = null + + var title: String? = null + var altTitle: String? = null + + var genre: String? = null + + var datePosted: Long? = null + var parent: String? = null + var visible: String? = null //Not a boolean + var language: String? = null + var translated: Boolean? = null + var size: Long? = null + var length: Int? = null + var favorites: Int? = null + var ratingCount: Int? = null + var averageRating: Double? = null + + var uploader: String? = null + + val tags: MutableMap<String, List<Tag>> = mutableMapOf() +} \ No newline at end of file diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/MetadataCopier.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/MetadataCopier.kt new file mode 100644 index 000000000..298a71b99 --- /dev/null +++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/MetadataCopier.kt @@ -0,0 +1,83 @@ +package eu.kanade.tachiyomi.extension.all.ehentai + +import eu.kanade.tachiyomi.source.model.SManga +import java.text.SimpleDateFormat +import java.util.* + +private const val EH_ARTIST_NAMESPACE = "artist" +private const val EH_AUTHOR_NAMESPACE = "author" + +private val ONGOING_SUFFIX = arrayOf( + "[ongoing]", + "(ongoing)", + "{ongoing}" +) + +val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) + +fun ExGalleryMetadata.copyTo(manga: SManga) { + url?.let { manga.url = it } + thumbnailUrl?.let { manga.thumbnail_url = it } + + (title ?: altTitle)?.let { manga.title = it } + + //Set artist (if we can find one) + tags[EH_ARTIST_NAMESPACE]?.let { + if (it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name) + } + //Set author (if we can find one) + tags[EH_AUTHOR_NAMESPACE]?.let { + if (it.isNotEmpty()) manga.author = it.joinToString(transform = Tag::name) + } + //Set genre + genre?.let { manga.genre = it } + + //Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes + //We default to completed + manga.status = SManga.COMPLETED + title?.let { t -> + if (ONGOING_SUFFIX.any { + t.endsWith(it, ignoreCase = true) + }) manga.status = SManga.ONGOING + } + + //Build a nice looking description out of what we know + val titleDesc = StringBuilder() + title?.let { titleDesc += "Title: $it\n" } + altTitle?.let { titleDesc += "Alternate Title: $it\n" } + + val detailsDesc = StringBuilder() + uploader?.let { detailsDesc += "Uploader: $it\n" } + datePosted?.let { detailsDesc += "Posted: ${EX_DATE_FORMAT.format(Date(it))}\n" } + visible?.let { detailsDesc += "Visible: $it\n" } + language?.let { + detailsDesc += "Language: $it" + if (translated == true) detailsDesc += " TR" + detailsDesc += "\n" + } + size?.let { detailsDesc += "File Size: ${humanReadableByteCount(it, true)}\n" } + length?.let { detailsDesc += "Length: $it pages\n" } + favorites?.let { detailsDesc += "Favorited: $it times\n" } + averageRating?.let { + detailsDesc += "Rating: $it" + ratingCount?.let { detailsDesc += " ($it)" } + detailsDesc += "\n" + } + + val tagsDesc = buildTagsDescription(this) + + manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString()) + .filter(String::isNotBlank) + .joinToString(separator = "\n") +} + +private fun buildTagsDescription(metadata: ExGalleryMetadata) + = StringBuilder("Tags:\n").apply { + //BiConsumer only available in Java 8, we have to use destructuring here + metadata.tags.forEach { (namespace, tags) -> + if (tags.isNotEmpty()) { + val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" }) + this += "▪ $namespace: $joinedTags\n" + } + } +} diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/Tag.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/Tag.kt new file mode 100644 index 000000000..7ee262b0f --- /dev/null +++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/Tag.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.extension.all.ehentai; + +/** + * Simple tag model + */ + +data class Tag(val name: String, val light: Boolean) \ No newline at end of file diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriFilter.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriFilter.kt new file mode 100644 index 000000000..e51416900 --- /dev/null +++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriFilter.kt @@ -0,0 +1,10 @@ +package eu.kanade.tachiyomi.extension.all.ehentai + +import android.net.Uri + +/** + * Uri filter + */ +interface UriFilter { + fun addToUri(builder: Uri.Builder) +} diff --git a/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriGroup.kt b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriGroup.kt new file mode 100644 index 000000000..431462f68 --- /dev/null +++ b/src/all/ehentai/src/eu/kanade/tachiyomi/extension/all/ehentai/UriGroup.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.extension.all.ehentai + +import android.net.Uri +import eu.kanade.tachiyomi.source.model.Filter + +/** + * UriGroup + */ +open class UriGroup<V>(name: String, state: List<V>) : Filter.Group<V>(name, state), UriFilter { + override fun addToUri(builder: Uri.Builder) { + state.forEach { + if (it is UriFilter) it.addToUri(builder) + } + } +} diff --git a/src/all/nhentai/build.gradle b/src/all/nhentai/build.gradle new file mode 100644 index 000000000..775a9d617 --- /dev/null +++ b/src/all/nhentai/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: nhentai' + pkgNameSuffix = "all.nhentai" + extClass = '.NHJapanese; .NHEnglish; .NHChinese; .NHSpeechless; .NHCzech; .NHEsperanto; .NHMongolian; .NHSlovak; .NHArabic; .NHUkrainian' + extVersionCode = 1 + extVersionSuffix = 1 + libVersion = '1.0' +} + +dependencies { + provided "com.google.code.gson:gson:2.8.0" + provided "com.github.salomonbrys.kotson:kotson:2.5.0" +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/MetadataCopier.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/MetadataCopier.kt new file mode 100644 index 000000000..de5c592ca --- /dev/null +++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/MetadataCopier.kt @@ -0,0 +1,72 @@ +package eu.kanade.tachiyomi.extension.all.nhentai + +import eu.kanade.tachiyomi.source.model.SManga +import java.text.SimpleDateFormat +import java.util.* + +private val ONGOING_SUFFIX = arrayOf( + "[ongoing]", + "(ongoing)", + "{ongoing}" +) + +private val EX_DATE_FORMAT = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US) + +fun NHentaiMetadata.copyTo(manga: SManga) { + url?.let { manga.url = it } + + mediaId?.let { mid -> + NHentaiMetadata.typeToExtension(thumbnailImageType)?.let { + manga.thumbnail_url = "https://t.nhentai.net/galleries/$mid/thumb.$it" + } + } + + manga.title = englishTitle ?: japaneseTitle ?: shortTitle!! + + //Set artist (if we can find one) + tags["artist"]?.let { + if (it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name) + } + + tags["category"]?.let { + if (it.isNotEmpty()) manga.genre = it.joinToString(transform = Tag::name) + } + + //Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes + //We default to completed + manga.status = SManga.COMPLETED + englishTitle?.let { t -> + if (ONGOING_SUFFIX.any { + t.endsWith(it, ignoreCase = true) + }) manga.status = SManga.ONGOING + } + + val titleDesc = StringBuilder() + englishTitle?.let { titleDesc += "English Title: $it\n" } + japaneseTitle?.let { titleDesc += "Japanese Title: $it\n" } + shortTitle?.let { titleDesc += "Short Title: $it\n" } + + val detailsDesc = StringBuilder() + uploadDate?.let { detailsDesc += "Upload Date: ${EX_DATE_FORMAT.format(Date(it))}\n" } + pageImageTypes.size.let { detailsDesc += "Length: $it pages\n" } + favoritesCount?.let { detailsDesc += "Favorited: $it times\n" } + scanlator?.nullIfBlank()?.let { detailsDesc += "Scanlator: $it\n" } + + val tagsDesc = buildTagsDescription(this) + + manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString()) + .filter(String::isNotBlank) + .joinToString(separator = "\n") +} + +private fun buildTagsDescription(metadata: NHentaiMetadata) + = StringBuilder("Tags:\n").apply { + //BiConsumer only available in Java 8, we have to use destructuring here + metadata.tags.forEach { (namespace, tags) -> + if (tags.isNotEmpty()) { + val joinedTags = tags.joinToString(separator = " ", transform = { "<${it.name}>" }) + this += "▪ $namespace: $joinedTags\n" + } + } +} + diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHLangs.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHLangs.kt new file mode 100644 index 000000000..4e90a4ddc --- /dev/null +++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHLangs.kt @@ -0,0 +1,29 @@ +package eu.kanade.tachiyomi.extension.all.nhentai + +/** + * NHentai languages + */ + +class NHJapanese : NHentai("ja", "japanese") +class NHEnglish : NHentai("en", "english") +class NHChinese : NHentai("zh", "chinese") +class NHSpeechless : NHentai("none", "speechless") +class NHCzech : NHentai("cs", "czech") +class NHEsperanto : NHentai("eo", "esperanto") +class NHMongolian : NHentai("mn", "mongolian") +class NHSlovak : NHentai("sk", "slovak") +class NHArabic : NHentai("ar", "arabic") +class NHUkrainian : NHentai("uk", "ukrainian") + +fun getAllNHentaiLanguages() = listOf( + NHJapanese(), + NHEnglish(), + NHChinese(), + NHSpeechless(), + NHCzech(), + NHEsperanto(), + NHMongolian(), + NHSlovak(), + NHArabic(), + NHUkrainian() +) diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHUtil.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHUtil.kt new file mode 100644 index 000000000..23e3a3546 --- /dev/null +++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHUtil.kt @@ -0,0 +1,17 @@ +package eu.kanade.tachiyomi.extension.all.nhentai + +/** + * Append Strings to StringBuilder with '+' operator + */ +operator fun StringBuilder.plusAssign(other: String) { + append(other) +} + +/** + * Return null if String is blank, otherwise returns the original String + * @returns null if the String is blank, otherwise returns the original String + */ +fun String?.nullIfBlank(): String? = if (isNullOrBlank()) + null +else + this diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentai.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentai.kt new file mode 100644 index 000000000..aa53cb152 --- /dev/null +++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentai.kt @@ -0,0 +1,216 @@ +package eu.kanade.tachiyomi.extension.all.nhentai + +import android.net.Uri +import com.github.salomonbrys.kotson.* +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.HttpSource +import okhttp3.Request +import okhttp3.Response +import rx.Observable + +/** + * NHentai source + */ + +open class NHentai(override val lang: String, val nhLang: String) : HttpSource() { + override val name = "nhentai" + + override val baseUrl = "https://nhentai.net" + + override val supportsLatest = true + + //TODO There is currently no way to get the most popular mangas + //TODO Instead, we delegate this to the latest updates thing to avoid confusing users with an empty screen + override fun fetchPopularManga(page: Int) = fetchLatestUpdates(page) + + override fun popularMangaRequest(page: Int) + = throw UnsupportedOperationException("This method should not be called!") + + override fun popularMangaParse(response: Response) + = throw UnsupportedOperationException("This method should not be called!") + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val uri = Uri.parse("$baseUrl/api/galleries/search").buildUpon() + uri.appendQueryParameter("query", "language:$nhLang $query") + uri.appendQueryParameter("page", page.toString()) + filters.forEach { + if (it is UriFilter) + it.addToUri(uri) + } + return nhGet(uri.toString(), page) + } + + override fun searchMangaParse(response: Response) = parseResultPage(response) + + override fun fetchLatestUpdates(page: Int) = fetchSearchManga(page, "", getFilterList()) + + override fun latestUpdatesRequest(page: Int) + = throw UnsupportedOperationException("This method should not be called!") + + override fun latestUpdatesParse(response: Response) + = throw UnsupportedOperationException("This method should not be called!") + + override fun mangaDetailsParse(response: Response) + = parseGallery(jsonParser.parse(response.body().string()).obj) + + //Hack so we can use a different URL for fetching manga details and opening the details in the browser + override fun fetchMangaDetails(manga: SManga) + = client.newCall(urlToDetailsRequest(manga.url)) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + + override fun mangaDetailsRequest(manga: SManga) = nhGet(manga.url) + + fun urlToDetailsRequest(url: String) = nhGet("$baseUrl/api/gallery/${url.substringAfterLast('/')}") + + fun parseResultPage(response: Response): MangasPage { + val res = jsonParser.parse(response.body().string()).obj + + res["error"]?.let { + throw RuntimeException("An error occurred while performing the search: $it") + } + + val results = res.getAsJsonArray("result")?.map { + parseGallery(it.obj) + } + val numPages = res["num_pages"].nullInt + if (results != null && numPages != null) + return MangasPage(results, numPages > response.request().tag() as Int) + return MangasPage(emptyList(), false) + } + + fun rawParseGallery(obj: JsonObject) = NHentaiMetadata().apply { + uploadDate = obj["upload_date"].nullLong + + favoritesCount = obj["num_favorites"].nullLong + + mediaId = obj["media_id"].nullString + + obj["title"].nullObj?.let { + japaneseTitle = it["japanese"].nullString + shortTitle = it["pretty"].nullString + englishTitle = it["english"].nullString + } + + obj["images"].nullObj?.let { + coverImageType = it["cover"]?.get("t").nullString + it["pages"].nullArray?.map { + it.nullObj?.get("t").nullString + }?.filterNotNull()?.let { + pageImageTypes.clear() + pageImageTypes.addAll(it) + } + thumbnailImageType = it["thumbnail"]?.get("t").nullString + } + + scanlator = obj["scanlator"].nullString + + id = obj["id"]?.asLong + + obj["tags"].nullArray?.map { + val asObj = it.obj + Pair(asObj["type"].nullString, asObj["name"].nullString) + }?.apply { + tags.clear() + }?.forEach { + if (it.first != null && it.second != null) + tags.getOrPut(it.first!!, { mutableListOf<Tag>() }).add(Tag(it.second!!, false)) + }!! + } + + fun parseGallery(obj: JsonObject) = SManga.create().apply { + rawParseGallery(obj).copyTo(this) + } + + fun lazyLoadMetadata(url: String) = + client.newCall(urlToDetailsRequest(url)) + .asObservableSuccess() + .map { + rawParseGallery(jsonParser.parse(it.body().string()).obj) + }!! + + override fun fetchChapterList(manga: SManga) + = Observable.just(listOf(SChapter.create().apply { + url = manga.url + name = "Chapter" + chapter_number = 1f + }))!! + + override fun fetchPageList(chapter: SChapter) + = lazyLoadMetadata(chapter.url).map { metadata -> + if (metadata.mediaId == null) emptyList() + else + metadata.pageImageTypes.mapIndexed { index, s -> + val imageUrl = imageUrlFromType(metadata.mediaId!!, index + 1, s) + Page(index, imageUrl!!, imageUrl) + } + }!! + + override fun fetchImageUrl(page: Page) = Observable.just(page.imageUrl!!)!! + + fun imageUrlFromType(mediaId: String, page: Int, t: String) = NHentaiMetadata.typeToExtension(t)?.let { + "https://i.nhentai.net/galleries/$mediaId/$page.$it" + } + + override fun chapterListParse(response: Response) + = throw UnsupportedOperationException("This method should not be called!") + + override fun pageListParse(response: Response) + = throw UnsupportedOperationException("This method should not be called!") + + override fun imageUrlParse(response: Response) + = throw UnsupportedOperationException("This method should not be called!") + + override fun getFilterList() = FilterList(SortFilter()) + + private class SortFilter : UriSelectFilter("Sort", "sort", arrayOf( + Pair("date", "Date"), + Pair("popular", "Popularity") + ), firstIsUnspecified = false) + + private fun nhGet(url: String, tag: Any? = null) = GET(url) + .newBuilder() + //Requested by nhentai admins to use a custom user agent + .header("User-Agent", + "Mozilla/5.0 (X11; Linux x86_64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/56.0.2924.87 " + + "Safari/537.36 " + + "Tachiyomi/1.0") + .tag(tag).build()!! + + /** + * Class that creates a select filter. Each entry in the dropdown has a name and a display name. + * If an entry is selected it is appended as a query parameter onto the end of the URI. + * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI. + */ + //vals: <name, display> + private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>, + val firstIsUnspecified: Boolean = true, + defaultValue: Int = 0) : + Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter { + override fun addToUri(uri: Uri.Builder) { + if (state != 0 || !firstIsUnspecified) + uri.appendQueryParameter(uriParam, vals[state].first) + } + } + + /** + * Represents a filter that is able to modify a URI. + */ + private interface UriFilter { + fun addToUri(uri: Uri.Builder) + } + + companion object { + val jsonParser by lazy { + JsonParser() + } + } +} diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentaiMetadata.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentaiMetadata.kt new file mode 100644 index 000000000..d29f67e2a --- /dev/null +++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/NHentaiMetadata.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.extension.all.nhentai + +/** + * NHentai metadata + */ + +class NHentaiMetadata { + + var id: Long? = null + + var url: String? + get() = id?.let { "/g/$it" } + set(a) { + id = a?.substringAfterLast('/')?.toLong() + } + + var uploadDate: Long? = null + + var favoritesCount: Long? = null + + var mediaId: String? = null + + var japaneseTitle: String? = null + var englishTitle: String? = null + var shortTitle: String? = null + + var coverImageType: String? = null + var pageImageTypes: MutableList<String> = mutableListOf() + var thumbnailImageType: String? = null + + var scanlator: String? = null + + val tags: MutableMap<String, MutableList<Tag>> = mutableMapOf() + + companion object { + fun typeToExtension(t: String?) = + when (t) { + "p" -> "png" + "j" -> "jpg" + else -> null + } + } +} + diff --git a/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/Tag.kt b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/Tag.kt new file mode 100644 index 000000000..005301bd9 --- /dev/null +++ b/src/all/nhentai/src/eu/kanade/tachiyomi/extension/all/nhentai/Tag.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.extension.all.nhentai + +/** + * Simple tag model + */ + +data class Tag(val name: String, val light: Boolean) diff --git a/src/en/mangago/build.gradle b/src/en/mangago/build.gradle new file mode 100644 index 000000000..7b631d988 --- /dev/null +++ b/src/en/mangago/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: Mangago' + pkgNameSuffix = "en.mangago" + extClass = '.Mangago' + extVersionCode = 1 + extVersionSuffix = 1 + libVersion = '1.0' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt b/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt new file mode 100644 index 000000000..3a80e97bf --- /dev/null +++ b/src/en/mangago/src/eu/kanade/tachiyomi/extension/en/mangago/Mangago.kt @@ -0,0 +1,245 @@ +package eu.kanade.tachiyomi.extension.en.mangago + +import android.net.Uri +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.* + +/** + * Mangago source + */ + +class Mangago : ParsedHttpSource() { + override val lang = "en" + override val supportsLatest = true + override val name = "Mangago" + override val baseUrl = "https://www.mangago.me" + + override val client = network.cloudflareClient!! + + //Hybrid selector that selects manga from either the genre listing or the search results + private val genreListingSelector = ".updatesli" + private val genreListingNextPageSelector = ".current+li > a" + + private val dateFormat = SimpleDateFormat("MMM d, yyyy", Locale.ENGLISH) + + override fun popularMangaSelector() = genreListingSelector + + private fun mangaFromElement(element: Element) = SManga.create().apply { + val linkElement = element.select(".thm-effect") + + setUrlWithoutDomain(linkElement.attr("href")) + + title = linkElement.attr("title") + + thumbnail_url = linkElement.first().child(0).attr("src") + } + + override fun popularMangaFromElement(element: Element) = mangaFromElement(element) + + override fun popularMangaNextPageSelector() = genreListingNextPageSelector + + //Hybrid selector that selects manga from either the genre listing or the search results + override fun searchMangaSelector() = "$genreListingSelector, .pic_list .box" + + override fun searchMangaFromElement(element: Element) = mangaFromElement(element) + + override fun searchMangaNextPageSelector() = genreListingNextPageSelector + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/genre/all/$page/?f=1&o=1&sortby=view&e=") + + override fun latestUpdatesSelector() = genreListingSelector + + override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + //If text search is active use text search, otherwise use genre search + val url = if (query.isNotBlank()) { + Uri.parse("$baseUrl/r/l_search/") + .buildUpon() + .appendQueryParameter("name", query) + .appendQueryParameter("page", page.toString()) + .toString() + } else { + val uri = Uri.parse("$baseUrl/genre/").buildUpon() + val genres = filters.flatMap { + (it as? GenreGroup)?.stateList ?: emptyList() + } + //Append included genres + val activeGenres = genres.filter { it.isIncluded() } + uri.appendPath(if (activeGenres.isEmpty()) + "all" + else + activeGenres.joinToString(",", transform = { it.name })) + //Append page number + uri.appendPath(page.toString()) + //Append excluded genres + uri.appendQueryParameter("e", + genres.filter { it.isExcluded() } + .joinToString(",", transform = GenreFilter::name)) + //Append uri filters + filters.forEach { + if (it is UriFilter) + it.addToUri(uri) + } + uri.toString() + } + return GET(url) + } + + override fun latestUpdatesNextPageSelector() = genreListingNextPageSelector + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + val coverElement = document.select(".left.cover > img") + + title = coverElement.attr("alt") + + thumbnail_url = coverElement.attr("src") + + document.select(".manga_right td").forEach { + when (it.getElementsByTag("label").text().trim().toLowerCase()) { + "status:" -> { + status = when (it.getElementsByTag("span").first().text().trim().toLowerCase()) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + "author:" -> { + author = it.getElementsByTag("a").first().text() + } + "genre(s):" -> { + genre = it.getElementsByTag("a").joinToString(transform = { it.text() }) + } + } + } + + description = document.getElementsByClass("manga_summary").first().ownText().trim() + } + + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/genre/all/$page/?f=1&o=1&sortby=update_date&e=") + + override fun chapterListSelector() = "#chapter_table > tbody > tr" + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + val link = element.getElementsByTag("a") + + setUrlWithoutDomain(link.attr("href")) + + name = link.text().trim() + + date_upload = dateFormat.parse(element.getElementsByClass("no").text().trim()).time + } + + override fun pageListParse(document: Document) + = document.getElementById("pagenavigation").getElementsByTag("a").mapIndexed { index, element -> + Page(index, element.attr("href")) + } + + override fun imageUrlParse(document: Document) = document.getElementById("page1").attr("src")!! + + override fun getFilterList() = FilterList( + //Mangago does not support genre filtering and text search at the same time + Filter.Header("NOTE: Ignored if using text search!"), + Filter.Separator(), + Filter.Header("Status"), + StatusFilter("Completed", "f"), + StatusFilter("Ongoing", "o"), + GenreGroup(), + SortFilter() + ) + + private class GenreGroup : UriFilterGroup<GenreFilter>("Genres", listOf( + GenreFilter("Yaoi"), + GenreFilter("Doujinshi"), + GenreFilter("Shounen Ai"), + GenreFilter("Shoujo"), + GenreFilter("Yuri"), + GenreFilter("Romance"), + GenreFilter("Fantasy"), + GenreFilter("Smut"), + GenreFilter("Adult"), + GenreFilter("School Life"), + GenreFilter("Mystery"), + GenreFilter("Comedy"), + GenreFilter("Ecchi"), + GenreFilter("Shounen"), + GenreFilter("Martial Arts"), + GenreFilter("Shoujo Ai"), + GenreFilter("Supernatural"), + GenreFilter("Drama"), + GenreFilter("Action"), + GenreFilter("Adventure"), + GenreFilter("Harem"), + GenreFilter("Historical"), + GenreFilter("Horror"), + GenreFilter("Josei"), + GenreFilter("Mature"), + GenreFilter("Mecha"), + GenreFilter("Psychological"), + GenreFilter("Sci-fi"), + GenreFilter("Seinen"), + GenreFilter("Slice Of Life"), + GenreFilter("Sports"), + GenreFilter("Gender Bender"), + GenreFilter("Tragedy"), + GenreFilter("Bara"), + GenreFilter("Shotacon") + )) + + private class GenreFilter(name: String) : Filter.TriState(name) + + private class StatusFilter(name: String, val uriParam: String) : Filter.CheckBox(name, true), UriFilter { + override fun addToUri(uri: Uri.Builder) { + uri.appendQueryParameter(uriParam, if (state) "1" else "0") + } + } + + private class SortFilter : UriSelectFilter("Sort", "sortby", arrayOf( + Pair("random", "Random"), + Pair("view", "Views"), + Pair("comment_count", "Comment Count"), + Pair("create_date", "Creation Date"), + Pair("update_date", "Update Date") + )) + + /** + * Class that creates a select filter. Each entry in the dropdown has a name and a display name. + * If an entry is selected it is appended as a query parameter onto the end of the URI. + * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI. + */ + //vals: <name, display> + private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>, + val firstIsUnspecified: Boolean = true, + defaultValue: Int = 0) : + Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter { + override fun addToUri(uri: Uri.Builder) { + if (state != 0 || !firstIsUnspecified) + uri.appendQueryParameter(uriParam, vals[state].first) + } + } + + /** + * Uri filter group + */ + private open class UriFilterGroup<V>(name: String, val stateList: List<V>) : Filter.Group<V>(name, stateList), UriFilter { + override fun addToUri(uri: Uri.Builder) { + stateList.forEach { + if (it is UriFilter) + it.addToUri(uri) + } + } + } + + /** + * Represents a filter that is able to modify a URI. + */ + private interface UriFilter { + fun addToUri(uri: Uri.Builder) + } +} diff --git a/src/en/mangapark/build.gradle b/src/en/mangapark/build.gradle new file mode 100644 index 000000000..fb28ebf1c --- /dev/null +++ b/src/en/mangapark/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: MangaPark' + pkgNameSuffix = "en.mangapark" + extClass = '.MangaPark' + extVersionCode = 1 + extVersionSuffix = 1 + libVersion = '1.0' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/mangapark/src/eu/kanade/tachiyomi/extension/en/mangapark/MangaPark.kt b/src/en/mangapark/src/eu/kanade/tachiyomi/extension/en/mangapark/MangaPark.kt new file mode 100644 index 000000000..34fc4eda8 --- /dev/null +++ b/src/en/mangapark/src/eu/kanade/tachiyomi/extension/en/mangapark/MangaPark.kt @@ -0,0 +1,291 @@ +package eu.kanade.tachiyomi.extension.en.mangapark + +import android.net.Uri +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import okhttp3.Request +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.text.SimpleDateFormat +import java.util.* + +/** + * MangaPark source + */ + +class MangaPark : ParsedHttpSource() { + override val lang = "en" + + override val supportsLatest = true + override val name = "MangaPark" + override val baseUrl = "https://mangapark.me" + + private val directorySelector = ".item" + private val directoryUrl = "/genre" + private val directoryNextPageSelector = ".paging.full > li:last-child > a" + + private val dateFormat = SimpleDateFormat("MMM d, yyyy, HH:mm a", Locale.ENGLISH) + + override fun popularMangaSelector() = directorySelector + + private fun mangaFromElement(element: Element) = SManga.create().apply { + val coverElement = element.getElementsByClass("cover").first() + url = coverElement.attr("href") + + title = coverElement.attr("title") + + thumbnail_url = coverElement.getElementsByTag("img").attr("src") + } + + override fun popularMangaFromElement(element: Element) = mangaFromElement(element) + + override fun popularMangaNextPageSelector() = directoryNextPageSelector + + override fun searchMangaSelector() = ".item" + + override fun searchMangaFromElement(element: Element) = mangaFromElement(element) + + override fun searchMangaNextPageSelector() = ".paging > li:last-child > a" + + override fun popularMangaRequest(page: Int) = GET("$baseUrl$directoryUrl/$page?views") + + override fun latestUpdatesSelector() = directorySelector + + override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val uri = Uri.parse("$baseUrl/search").buildUpon() + uri.appendQueryParameter("q", query) + filters.forEach { + if (it is UriFilter) + it.addToUri(uri) + } + uri.appendQueryParameter("page", page.toString()) + return GET(uri.toString()) + } + + override fun latestUpdatesNextPageSelector() = directoryNextPageSelector + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + val coverElement = document.select(".cover > img").first() + + title = coverElement.attr("title") + + thumbnail_url = coverElement.attr("src") + + document.select(".attr > tbody > tr").forEach { + val type = it.getElementsByTag("th").first().text().trim().toLowerCase() + when (type) { + "author(s)" -> { + author = it.getElementsByTag("a").joinToString(transform = Element::text) + } + "artist(s)" -> { + artist = it.getElementsByTag("a").joinToString(transform = Element::text) + } + "genre(s)" -> { + genre = it.getElementsByTag("a").joinToString(transform = Element::text) + } + "status" -> { + status = when (it.getElementsByTag("td").text().trim().toLowerCase()) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + } + } + + description = document.getElementsByClass("summary").text().trim() + } + + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl$directoryUrl/$page?latest") + + //TODO MangaPark has "versioning" + //TODO Currently we just use the version that is expanded by default + //TODO Maybe make it possible for users to view the other versions as well? + override fun chapterListSelector() = ".stream:not(.collapsed) .volume .chapter li" + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + url = element.select("em > a").last().attr("href") + + name = element.getElementsByClass("ch").text() + + date_upload = dateFormat.parse(element.getElementsByTag("i").text().trim()).time + } + + override fun pageListParse(document: Document) + = document.getElementsByClass("img").map { + Page(it.attr("i").toInt() - 1, "", it.attr("src")) + } + + //Unused, we can get image urls directly from the chapter page + override fun imageUrlParse(document: Document) + = throw UnsupportedOperationException("This method should not be called!") + + override fun getFilterList() = FilterList( + AuthorArtistText(), + SearchTypeFilter("Title query", "name-match"), + SearchTypeFilter("Author/Artist query", "autart-match"), + SortFilter(), + GenreGroup(), + GenreInclusionFilter(), + ChapterCountFilter(), + StatusFilter(), + RatingFilter(), + TypeFilter(), + YearFilter() + ) + + private class SearchTypeFilter(name: String, val uriParam: String) : + Filter.Select<String>(name, STATE_MAP), UriFilter { + override fun addToUri(uri: Uri.Builder) { + uri.appendQueryParameter(uriParam, STATE_MAP[state]) + } + + companion object { + private val STATE_MAP = arrayOf("contain", "begin", "end") + } + } + + private class AuthorArtistText : Filter.Text("Author/Artist"), UriFilter { + override fun addToUri(uri: Uri.Builder) { + uri.appendQueryParameter("autart", state) + } + } + + private class GenreFilter(val uriParam: String, displayName: String) : Filter.TriState(displayName) + + private class GenreGroup : Filter.Group<GenreFilter>("Genres", listOf( + GenreFilter("4-koma", "4 koma"), + GenreFilter("action", "Action"), + GenreFilter("adult", "Adult"), + GenreFilter("adventure", "Adventure"), + GenreFilter("award-winning", "Award winning"), + GenreFilter("comedy", "Comedy"), + GenreFilter("cooking", "Cooking"), + GenreFilter("demons", "Demons"), + GenreFilter("doujinshi", "Doujinshi"), + GenreFilter("drama", "Drama"), + GenreFilter("ecchi", "Ecchi"), + GenreFilter("fantasy", "Fantasy"), + GenreFilter("gender-bender", "Gender bender"), + GenreFilter("harem", "Harem"), + GenreFilter("historical", "Historical"), + GenreFilter("horror", "Horror"), + GenreFilter("josei", "Josei"), + GenreFilter("magic", "Magic"), + GenreFilter("martial-arts", "Martial arts"), + GenreFilter("mature", "Mature"), + GenreFilter("mecha", "Mecha"), + GenreFilter("medical", "Medical"), + GenreFilter("music", "Music"), + GenreFilter("mystery", "Mystery"), + GenreFilter("one-shot", "One shot"), + GenreFilter("psychological", "Psychological"), + GenreFilter("romance", "Romance"), + GenreFilter("school-life", "School life"), + GenreFilter("sci-fi", "Sci fi"), + GenreFilter("seinen", "Seinen"), + GenreFilter("shoujo", "Shoujo"), + GenreFilter("shoujo-ai", "Shoujo ai"), + GenreFilter("shounen", "Shounen"), + GenreFilter("shounen-ai", "Shounen ai"), + GenreFilter("slice-of-life", "Slice of life"), + GenreFilter("smut", "Smut"), + GenreFilter("sports", "Sports"), + GenreFilter("supernatural", "Supernatural"), + GenreFilter("tragedy", "Tragedy"), + GenreFilter("webtoon", "Webtoon"), + GenreFilter("yaoi", "Yaoi"), + GenreFilter("yuri", "Yuri") + )), UriFilter { + override fun addToUri(uri: Uri.Builder) { + uri.appendQueryParameter("genres", state.filter { it.isIncluded() }.map { it.uriParam }.joinToString(",")) + uri.appendQueryParameter("genres-exclude", state.filter { it.isExcluded() }.map { it.uriParam }.joinToString(",")) + } + } + + private class GenreInclusionFilter : UriSelectFilter("Genre inclusion", "genres-mode", arrayOf( + Pair("and", "And mode"), + Pair("or", "Or mode") + )) + + private class ChapterCountFilter : UriSelectFilter("Chapter count", "chapters", arrayOf( + Pair("any", "Any"), + Pair("1", "1 +"), + Pair("5", "5 +"), + Pair("10", "10 +"), + Pair("20", "20 +"), + Pair("30", "30 +"), + Pair("40", "40 +"), + Pair("50", "50 +"), + Pair("100", "100 +"), + Pair("150", "150 +"), + Pair("200", "200 +") + )) + + private class StatusFilter : UriSelectFilter("Status", "status", arrayOf( + Pair("any", "Any"), + Pair("completed", "Completed"), + Pair("ongoing", "Ongoing") + )) + + private class RatingFilter : UriSelectFilter("Rating", "rating", arrayOf( + Pair("any", "Any"), + Pair("5", "5 stars"), + Pair("4", "4 stars"), + Pair("3", "3 stars"), + Pair("2", "2 stars"), + Pair("1", "1 star"), + Pair("0", "0 stars") + )) + + private class TypeFilter : UriSelectFilter("Type", "types", arrayOf( + Pair("any", "Any"), + Pair("manga", "Japanese Manga"), + Pair("manhwa", "Korean Manhwa"), + Pair("manhua", "Chinese Manhua"), + Pair("unknown", "Unknown") + )) + + private class YearFilter : UriSelectFilter("Release year", "years", + arrayOf(Pair("any", "Any"), + //Get all years between today and 1946 + *(Calendar.getInstance().get(Calendar.YEAR) downTo 1946).map { + Pair(it.toString(), it.toString()) + }.toTypedArray() + ) + ) + + private class SortFilter : UriSelectFilter("Sort", "orderby", arrayOf( + Pair("a-z", "A-Z"), + Pair("views", "Views"), + Pair("rating", "Rating"), + Pair("latest", "Latest"), + Pair("add", "New manga") + ), firstIsUnspecified = false, defaultValue = 1) + + /** + * Class that creates a select filter. Each entry in the dropdown has a name and a display name. + * If an entry is selected it is appended as a query parameter onto the end of the URI. + * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI. + */ + //vals: <name, display> + private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>, + val firstIsUnspecified: Boolean = true, + defaultValue: Int = 0) : + Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter { + override fun addToUri(uri: Uri.Builder) { + if (state != 0 || !firstIsUnspecified) + uri.appendQueryParameter(uriParam, vals[state].first) + } + } + + /** + * Represents a filter that is able to modify a URI. + */ + private interface UriFilter { + fun addToUri(uri: Uri.Builder) + } +} diff --git a/src/en/tapastic/build.gradle b/src/en/tapastic/build.gradle new file mode 100644 index 000000000..49c2472d4 --- /dev/null +++ b/src/en/tapastic/build.gradle @@ -0,0 +1,18 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: Tapastic' + pkgNameSuffix = "en.tapastic" + extClass = '.Tapastic' + extVersionCode = 1 + extVersionSuffix = 1 + libVersion = '1.0' +} + +dependencies { + provided "com.google.code.gson:gson:2.8.0" + provided "com.github.salomonbrys.kotson:kotson:2.5.0" +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt b/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt new file mode 100644 index 000000000..d023094c0 --- /dev/null +++ b/src/en/tapastic/src/eu/kanade/tachiyomi/extension/en/tapastic/Tapastic.kt @@ -0,0 +1,212 @@ +package eu.kanade.tachiyomi.extension.en.tapastic + +import android.net.Uri +import com.github.salomonbrys.kotson.* +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element + +class Tapastic : ParsedHttpSource() { + override val lang = "en" + override val supportsLatest = true + override val name = "Tapastic" + override val baseUrl = "https://tapas.io" + + private val browseMangaSelector = ".content-item" + private val nextPageSelector = "a.paging-btn.next" + + private val jsonParser by lazy { JsonParser() } + + override fun popularMangaSelector() = browseMangaSelector + + private fun mangaFromElement(element: Element) = SManga.create().apply { + val thumb = element.getElementsByClass("thumb-wrap") + + url = thumb.attr("href") + + title = element.getElementsByClass("title").text().trim() + + thumbnail_url = thumb.select("img").attr("src") + } + + override fun popularMangaFromElement(element: Element) = mangaFromElement(element) + + override fun popularMangaNextPageSelector() = nextPageSelector + + override fun searchMangaSelector() = "$browseMangaSelector, .search-item-wrap" + + override fun searchMangaFromElement(element: Element) = mangaFromElement(element) + + override fun searchMangaNextPageSelector() = nextPageSelector + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/comics?pageNumber=$page&browse=POPULAR") + + override fun latestUpdatesSelector() = browseMangaSelector + + override fun latestUpdatesFromElement(element: Element) = mangaFromElement(element) + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + //If there is any search text, use text search, otherwise use filter search + val uri = if (query.isNotBlank()) { + Uri.parse("$baseUrl/search") + .buildUpon() + .appendQueryParameter("t", "COMICS") + .appendQueryParameter("q", query) + } else { + val uri = Uri.parse("$baseUrl/comics").buildUpon() + //Append uri filters + filters.forEach { + if (it is UriFilter) + it.addToUri(uri) + } + uri + } + //Append page number + uri.appendQueryParameter("pageNumber", page.toString()) + return GET(uri.toString()) + } + + override fun latestUpdatesNextPageSelector() = nextPageSelector + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + title = document.getElementsByClass("series-header-title").text().trim() + + author = document.getElementsByClass("name").text().trim() + artist = author + + description = document.getElementById("series-desc-body").text().trim() + + genre = document.getElementsByClass("genre").text() + + status = SManga.UNKNOWN + } + + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/comics?pageNumber=$page&browse=FRESH") + + override fun chapterListParse(response: Response) + //Chapters are stored in JavaScript as JSON! + = response.asJsoup().getElementsByTag("script").filter { + it.data().trim().startsWith("var _data") + }.flatMap { + val text = it.data() + val episodeVar = text.indexOf("episodeList") + if (episodeVar == -1) + return@flatMap emptyList<SChapter>() + + val episodeLeftBracket = text.indexOf('[', startIndex = episodeVar) + if (episodeLeftBracket == -1) + return@flatMap emptyList<SChapter>() + + val endOfLine = text.indexOf('\n', startIndex = episodeLeftBracket) + if (endOfLine == -1) + return@flatMap emptyList<SChapter>() + + val episodeRightBracket = text.lastIndexOf(']', startIndex = endOfLine) + if (episodeRightBracket == -1) + return@flatMap emptyList<SChapter>() + + val episodeListText = text.substring(episodeLeftBracket..episodeRightBracket) + + jsonParser.parse(episodeListText).array.map { + val json = it.asJsonObject + //Ensure that the chapter is published (tapastic allows scheduling chapters) + if (json["orgScene"].int != 0) + SChapter.create().apply { + url = "/episode/${json["id"].string}" + + name = json["title"].string + + date_upload = json["publishDate"].long + + chapter_number = json["scene"].float + } + else null + }.filterNotNull().sortedByDescending(SChapter::chapter_number) + } + + override fun chapterListSelector() + = throw UnsupportedOperationException("This method should not be called!") + + override fun chapterFromElement(element: Element) + = throw UnsupportedOperationException("This method should not be called!") + + override fun pageListParse(document: Document) + = document.getElementsByClass("art-image").mapIndexed { index, element -> + Page(index, "", element.attr("src")) + } + + //Unused, we can get image urls directly from the chapter page + override fun imageUrlParse(document: Document) + = throw UnsupportedOperationException("This method should not be called!") + + override fun getFilterList() = FilterList( + //Tapastic does not support genre filtering and text search at the same time + Filter.Header("NOTE: Ignored if using text search!"), + Filter.Separator(), + FilterFilter(), + GenreFilter(), + Filter.Separator(), + Filter.Header("Sort is ignored when filter is active!"), + SortFilter() + ) + + private class FilterFilter : UriSelectFilter("Filter", "browse", arrayOf( + Pair("ALL", "None"), + Pair("POPULAR", "Popular"), + Pair("TRENDING", "Trending"), + Pair("FRESH", "Fresh"), + Pair("TAPASTIC", "Staff Picks") + ), firstIsUnspecified = false, defaultValue = 1) + + private class GenreFilter : UriSelectFilter("Genre", "genreIds", arrayOf( + Pair("", "Any"), + Pair("7", "Action"), + Pair("2", "Comedy"), + Pair("8", "Drama"), + Pair("3", "Fantasy"), + Pair("9", "Gaming"), + Pair("6", "Horror"), + Pair("10", "Mystery"), + Pair("5", "Romance"), + Pair("4", "Science Fiction"), + Pair("1", "Slice of Life") + )) + + private class SortFilter : UriSelectFilter("Sort", "sortType", arrayOf( + Pair("SUBSCRIBE", "Subscribers"), + Pair("LIKE", "Likes"), + Pair("VIEW", "Views"), + Pair("COMMENT", "Comments"), + Pair("CREATED", "Date"), + Pair("TITLE", "Name") + )) + + /** + * Class that creates a select filter. Each entry in the dropdown has a name and a display name. + * If an entry is selected it is appended as a query parameter onto the end of the URI. + * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI. + */ + //vals: <name, display> + private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>, + val firstIsUnspecified: Boolean = true, + defaultValue: Int = 0) : + Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter { + override fun addToUri(uri: Uri.Builder) { + if (state != 0 || !firstIsUnspecified) + uri.appendQueryParameter(uriParam, vals[state].first) + } + } + + /** + * Represents a filter that is able to modify a URI. + */ + private interface UriFilter { + fun addToUri(uri: Uri.Builder) + } +} diff --git a/src/ja/senmanga/build.gradle b/src/ja/senmanga/build.gradle new file mode 100644 index 000000000..9bf70d2d4 --- /dev/null +++ b/src/ja/senmanga/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: Sen Manga' + pkgNameSuffix = "ja.senmanga" + extClass = '.SenManga' + extVersionCode = 1 + extVersionSuffix = 1 + libVersion = '1.0' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/ja/senmanga/src/eu/kanade/tachiyomi/extension/ja/senmanga/SenManga.kt b/src/ja/senmanga/src/eu/kanade/tachiyomi/extension/ja/senmanga/SenManga.kt new file mode 100644 index 000000000..76c6ee4af --- /dev/null +++ b/src/ja/senmanga/src/eu/kanade/tachiyomi/extension/ja/senmanga/SenManga.kt @@ -0,0 +1,327 @@ +package eu.kanade.tachiyomi.extension.ja.senmanga + +import android.net.Uri +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.ParsedHttpSource +import eu.kanade.tachiyomi.util.asJsoup +import okhttp3.Response +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.util.* + +/** + * Sen Manga source + */ + +class SenManga : ParsedHttpSource() { + override val lang: String = "ja" + + //Latest updates currently returns duplicate manga as it separates manga into chapters + override val supportsLatest = false + override val name = "Sen Manga" + override val baseUrl = "http://raw.senmanga.com" + + override val client = super.client.newBuilder().addInterceptor { + //Intercept any image requests and add a referer to them + //Enables bandwidth stealing feature + val request = if (it.request().url().pathSegments().firstOrNull()?.trim()?.toLowerCase() == "viewer") { + it.request().newBuilder() + .addHeader("Referer", it.request().url().newBuilder() + .removePathSegment(0) + .toString()) + .build() + } else it.request() + it.proceed(request) + }.build()!! + + //Sen Manga doesn't follow the specs and decides to use multiple elements with the same ID on the page... + override fun popularMangaSelector() = "#manga-list" + + override fun popularMangaFromElement(element: Element) = SManga.create().apply { + val linkElement = element.select("h1 a") + + url = linkElement.attr("href") + + title = linkElement.text() + + thumbnail_url = baseUrl + element.getElementsByClass("series-cover").attr("src") + } + + override fun popularMangaNextPageSelector() = "#Navigation > span > ul > li:nth-last-child(2)" + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + + val mangas = document.select(popularMangaSelector()).map { element -> + popularMangaFromElement(element) + } + + val hasNextPage = document.select(popularMangaNextPageSelector()).let { + it.isNotEmpty() && it.text().trim().toLowerCase() == "Next" + } + + return MangasPage(mangas, hasNextPage) + } + + override fun searchMangaSelector() = ".search-results" + + override fun searchMangaFromElement(element: Element) = SManga.create().apply { + val coverImage = element.getElementsByTag("img") + + url = coverImage.parents().attr("href") + + title = coverImage.attr("alt") + + thumbnail_url = baseUrl + coverImage.attr("src") + } + + //Sen Manga search returns one page max! + override fun searchMangaNextPageSelector() = null + + override fun popularMangaRequest(page: Int) = GET("$baseUrl/Manga/?order=popular&page=$page") + + override fun latestUpdatesSelector() + = throw UnsupportedOperationException("This method should not be called!") + + override fun latestUpdatesFromElement(element: Element) + = throw UnsupportedOperationException("This method should not be called!") + + override fun searchMangaParse(response: Response) + = if (response.request().url().pathSegments().firstOrNull()?.toLowerCase() != "search.php") { + //Use popular manga parser if we are not actually doing text search + popularMangaParse(response) + } else { + val document = response.asJsoup() + + val mangas = document.select(searchMangaSelector()).map { element -> + searchMangaFromElement(element) + } + + MangasPage(mangas, false) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) + = GET(if (query.isNullOrBlank()) { + val genreFilter = filters.find { it is GenreFilter } as GenreFilter + val sortFilter = filters.find { it is SortFilter } as SortFilter + //If genre sort is not active or sort settings are changed + if (!sortFilter.isDefault() || genreFilter.genrePath() == ALL_GENRES_PATH) { + val uri = Uri.parse("$baseUrl/Manga/") + .buildUpon() + sortFilter.addToUri(uri) + uri.toString() + } else "$baseUrl/directory/category/${genreFilter.genrePath()}/" + } else { + Uri.parse("$baseUrl/Search.php") + .buildUpon() + .appendQueryParameter("q", query) + .toString() + }) + + override fun latestUpdatesNextPageSelector() + = throw UnsupportedOperationException("This method should not be called!") + + override fun mangaDetailsParse(document: Document) = SManga.create().apply { + title = document.select("h1[itemprop=name]").text() + + thumbnail_url = baseUrl + document.select(".cover > img").attr("src") + + val seriesDesc = document.getElementsByClass("series_desc") + + //Get the next paragraph after paragraph with "Categorize in:" + genre = seriesDesc.first() + .children() + .find { + it.tagName().toLowerCase() == "p" + && it.text().trim().toLowerCase() == "categorize in:" + }?.nextElementSibling() + ?.text() + ?.trim() + + + author = seriesDesc.select("div > span")?.text()?.trim() + + seriesDesc?.first()?.children()?.forEach { + val keyText = it.select("p > strong").text().trim().toLowerCase() + val valueText = it.select("p > .desc").text().trim() + + when (keyText) { + "artist:" -> artist = valueText + "status:" -> status = when (valueText.toLowerCase()) { + "ongoing" -> SManga.ONGOING + "complete" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + } + + description = seriesDesc.select("div[itemprop=description]").text() + } + + override fun latestUpdatesRequest(page: Int) + = throw UnsupportedOperationException("This method should not be called!") + + //This may be unreliable as Sen Manga breaks the specs by having multiple elements with the same ID + override fun chapterListSelector() = "#post > table > tbody > tr:not(.headline)" + + override fun chapterFromElement(element: Element) = SChapter.create().apply { + val linkElement = element.getElementsByTag("a") + + url = linkElement.attr("href") + + name = linkElement.text() + + chapter_number = element.child(0).text().trim().toFloatOrNull() ?: -1f + + date_upload = parseRelativeDate(element.children().last().text().trim().toLowerCase()) + } + + /** + * Parses dates in this form: + * `11 days ago` + */ + private fun parseRelativeDate(date: String): Long { + val trimmedDate = date.split(" ") + + if (trimmedDate[2] != "ago") return 0 + + val number = trimmedDate[0].toIntOrNull() ?: return 0 + val unit = trimmedDate[1].removeSuffix("s") //Remove 's' suffix + + val now = Calendar.getInstance() + + //Map English unit to Java unit + val javaUnit = when (unit) { + "year" -> Calendar.YEAR + "month" -> Calendar.MONTH + "week" -> Calendar.WEEK_OF_MONTH + "day" -> Calendar.DAY_OF_MONTH + "hour" -> Calendar.HOUR + "minute" -> Calendar.MINUTE + "second" -> Calendar.SECOND + else -> return 0 + } + + now.add(javaUnit, -number) + + return now.timeInMillis + } + + override fun pageListParse(document: Document): List<Page> { + //Base URI (document URI but without page index) + val baseUri = Uri.parse(baseUrl).buildUpon().apply { + Uri.parse(document.baseUri()).pathSegments.let { + it.take(it.size - 1) + }.forEach { + appendPath(it) + } + }.build() + + //Base Image URI (document URI but without page index and with "viewer" inserted as first path segment + val baseImageUri = Uri.parse(baseUrl).buildUpon().appendPath("viewer").apply { + baseUri.pathSegments.forEach { + appendPath(it) + } + }.build() + + return document.select("select[name=page] > option").map { + val index = it.attr("value") + + val uri = baseUri.buildUpon().appendPath(index).build() + + val imageUriBuilder = baseImageUri.buildUpon().appendPath(index).build() + + Page(index.toInt() - 1, uri.toString(), imageUriBuilder.toString()) + } + } + + //We are able to get the image URL directly from the page list + override fun imageUrlParse(document: Document) + = throw UnsupportedOperationException("This method should not be called!") + + override fun getFilterList() = FilterList( + Filter.Header("NOTE: Ignored if using text search!"), + GenreFilter(), + Filter.Header("NOTE: Sort ignores genres search!"), + SortFilter() + ) + + private class GenreFilter : Filter.Select<String>("Genre", GENRES.map { it.second }.toTypedArray()) { + fun genrePath() = GENRES[state].first + } + + private class SortFilter : UriSelectFilter("Sort", "order", arrayOf( + Pair("popular", "Popularity"), + Pair("title", "Title"), + Pair("rating", "Rating") + ), false) { + fun isDefault() = state == 0 + } + + /** + * Class that creates a select filter. Each entry in the dropdown has a name and a display name. + * If an entry is selected it is appended as a query parameter onto the end of the URI. + * If `firstIsUnspecified` is set to true, if the first entry is selected, nothing will be appended on the the URI. + */ + //vals: <name, display> + private open class UriSelectFilter(displayName: String, val uriParam: String, val vals: Array<Pair<String, String>>, + val firstIsUnspecified: Boolean = true, + defaultValue: Int = 0) : + Filter.Select<String>(displayName, vals.map { it.second }.toTypedArray(), defaultValue), UriFilter { + override fun addToUri(uri: Uri.Builder) { + if (state != 0 || !firstIsUnspecified) + uri.appendQueryParameter(uriParam, vals[state].first) + } + } + + /** + * Represents a filter that is able to modify a URI. + */ + private interface UriFilter { + fun addToUri(uri: Uri.Builder) + } + + companion object { + private val ALL_GENRES_PATH = "all" + //<path, display name> + private val GENRES = listOf( + Pair(ALL_GENRES_PATH, "All"), + Pair("Action", "Action"), + Pair("Adult", "Adult"), + Pair("Adventure", "Adventure"), + Pair("Comedy", "Comedy"), + Pair("Cooking", "Cooking"), + Pair("Drama", "Drama"), + Pair("Ecchi", "Ecchi"), + Pair("Fantasy", "Fantasy"), + Pair("Gender-Bender", "Gender Bender"), + Pair("Harem", "Harem"), + Pair("Historical", "Historical"), + Pair("Horror", "Horror"), + Pair("Josei", "Josei"), + Pair("Light_Novel", "Light Novel"), + Pair("Martial_Arts", "Martial Arts"), + Pair("Mature", "Mature"), + Pair("Music", "Music"), + Pair("Mystery", "Mystery"), + Pair("Psychological", "Psychological"), + Pair("Romance", "Romance"), + Pair("School_Life", "School Life"), + Pair("Sci-Fi", "Sci-Fi"), + Pair("Seinen", "Seinen"), + Pair("Shoujo", "Shoujo"), + Pair("Shoujo-Ai", "Shoujo Ai"), + Pair("Shounen", "Shounen"), + Pair("Shounen-Ai", "Shounen Ai"), + Pair("Slice_of_Life", "Slice of Life"), + Pair("Smut", "Smut"), + Pair("Sports", "Sports"), + Pair("Supernatural", "Supernatural"), + Pair("Tragedy", "Tragedy"), + Pair("Webtoons", "Webtoons"), + Pair("Yaoi", "Yaoi"), + Pair("Yuri", "Yuri") + ) + } +}