diff --git a/src/all/hentaihand/AndroidManifest.xml b/src/all/hentaihand/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/all/hentaihand/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/hentaihand/build.gradle b/src/all/hentaihand/build.gradle new file mode 100644 index 000000000..c205ecbf1 --- /dev/null +++ b/src/all/hentaihand/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + extName = 'HentaiHand' + pkgNameSuffix = 'all.hentaihand' + extClass = '.HentaiHandFactory' + extVersionCode = 1 + libVersion = '1.2' + containsNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/hentaihand/res/mipmap-hdpi/ic_launcher.png b/src/all/hentaihand/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..336381de2 Binary files /dev/null and b/src/all/hentaihand/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/hentaihand/res/mipmap-mdpi/ic_launcher.png b/src/all/hentaihand/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..c90b88eae Binary files /dev/null and b/src/all/hentaihand/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/hentaihand/res/mipmap-xhdpi/ic_launcher.png b/src/all/hentaihand/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..c8b45fbb8 Binary files /dev/null and b/src/all/hentaihand/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/hentaihand/res/mipmap-xxhdpi/ic_launcher.png b/src/all/hentaihand/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..739c509e2 Binary files /dev/null and b/src/all/hentaihand/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/hentaihand/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/hentaihand/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..d01b1eeee Binary files /dev/null and b/src/all/hentaihand/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/hentaihand/res/web_hi_res_512.png b/src/all/hentaihand/res/web_hi_res_512.png new file mode 100644 index 000000000..9a2c58d6d Binary files /dev/null and b/src/all/hentaihand/res/web_hi_res_512.png differ diff --git a/src/all/hentaihand/src/eu/kanade/tachiyomi/extension/all/hentaihand/HentaiHand.kt b/src/all/hentaihand/src/eu/kanade/tachiyomi/extension/all/hentaihand/HentaiHand.kt new file mode 100644 index 000000000..d37b36559 --- /dev/null +++ b/src/all/hentaihand/src/eu/kanade/tachiyomi/extension/all/hentaihand/HentaiHand.kt @@ -0,0 +1,256 @@ +package eu.kanade.tachiyomi.extension.all.hentaihand + +import android.annotation.SuppressLint +import com.github.salomonbrys.kotson.fromJson +import com.github.salomonbrys.kotson.get +import com.github.salomonbrys.kotson.nullObj +import com.github.salomonbrys.kotson.nullString +import com.google.gson.Gson +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import eu.kanade.tachiyomi.annotations.Nsfw +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.HttpSource +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import rx.schedulers.Schedulers +import java.text.SimpleDateFormat + +@Nsfw +class HentaiHand(override val lang: String, val hhLangId: Int) : HttpSource() { + + override val baseUrl: String = "https://hentaihand.com" + override val name: String = "HentaiHand" + override val supportsLatest = true + + private val gson = Gson() + + override val client: OkHttpClient = network.cloudflareClient + + private fun parseGenericResponse(response: Response): MangasPage { + val data = gson.fromJson(response.body()!!.string()) + return MangasPage( + data.getAsJsonArray("data").map { + SManga.create().apply { + url = "/en/comic/${it["slug"].asString}" + title = it["title"].asString + thumbnail_url = it["thumb_url"].asString + } + }, + !data["next_page_url"].isJsonNull + ) + } + + // Popular + + override fun popularMangaParse(response: Response): MangasPage = parseGenericResponse(response) + + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/api/comics?page=$page&sort=popularity&order=desc&duration=all&languages=$hhLangId") + } + + // Latest + + override fun latestUpdatesParse(response: Response): MangasPage = parseGenericResponse(response) + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/api/comics?page=$page&sort=uploaded_at&order=desc&duration=week&languages=$hhLangId") + } + + // Search + + override fun searchMangaParse(response: Response): MangasPage = parseGenericResponse(response) + + private fun lookupFilterId(query: String, uri: String): Int? { + // filter query needs to be resolved to an ID + return client.newCall(GET("$baseUrl/api/$uri?q=$query")) + .asObservableSuccess() + .subscribeOn(Schedulers.io()) + .map { + val data = gson.fromJson(it.body()!!.string()) + // only the first tag will be used + data.getAsJsonArray("data").firstOrNull()?.let { t -> t["id"].asInt } + }.toBlocking().first() + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + + val url = HttpUrl.parse("$baseUrl/api/comics")!!.newBuilder() + .addQueryParameter("page", page.toString()) + .addQueryParameter("q", query) + .addQueryParameter("languages", hhLangId.toString()) + + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> + when (filter) { + is SortFilter -> url.addQueryParameter("sort", getSortPairs()[filter.state].second) + is OrderFilter -> url.addQueryParameter("order", getOrderPairs()[filter.state].second) + is DurationFilter -> url.addQueryParameter("duration", getDurationPairs()[filter.state].second) + is AttributesGroupFilter -> filter.state.forEach { + if (it.state) url.addQueryParameter("attributes", it.value) + } + is LookupFilter -> { + filter.state.split(",").map { it.trim() }.filter { it.isNotBlank() }.map { + lookupFilterId(it, filter.uri) ?: throw Exception("No ${filter.singularName} \"$it\" was found") + }.forEach { + if (!(filter.uri == "languages" && it == hhLangId)) + url.addQueryParameter(filter.uri, it.toString()) + } + } + else -> {} + } + } + + return GET(url.toString()) + } + + // Details + + private fun tagArrayToString(array: JsonArray, key: String = "name"): String? { + if (array.size() == 0) + return null + return array.joinToString { it[key].asString } + } + + private fun mangaDetailsApiRequest(manga: SManga): Request { + val slug = manga.url.removePrefix("/en/comic/") + return GET("$baseUrl/api/comics/$slug") + } + + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(mangaDetailsApiRequest(manga)) + .asObservableSuccess() + .map { mangaDetailsParse(it).apply { initialized = true } } + } + + override fun mangaDetailsParse(response: Response): SManga { + val data = gson.fromJson(response.body()!!.string()) + return SManga.create().apply { + + artist = tagArrayToString(data.getAsJsonArray("artists")) + author = tagArrayToString(data.getAsJsonArray("authors")) ?: artist + + genre = listOf("tags", "relationships").map { + data.getAsJsonArray(it).map { t -> t["name"].asString } + }.flatten().distinct().joinToString() + + status = SManga.COMPLETED + + description = listOf( + Pair("Alternative Title", data["alternative_title"].nullString), + Pair("Groups", tagArrayToString(data.getAsJsonArray("groups"))), + Pair("Description", data["description"].nullString), + Pair("Pages", data["pages"].asInt.toString()), + Pair("Category", data["category"].nullObj?.get("name")?.asString), + Pair("Language", data["language"].nullObj?.get("name")?.asString), + Pair("Parodies", tagArrayToString(data.getAsJsonArray("parodies"))), + Pair("Characters", tagArrayToString(data.getAsJsonArray("characters"))) + ).filter { !it.second.isNullOrEmpty() }.joinToString("\n\n") { "${it.first}:\n${it.second}" } + } + } + + // Chapters + + override fun chapterListRequest(manga: SManga): Request = mangaDetailsApiRequest(manga) + + override fun chapterListParse(response: Response): List { + val data = gson.fromJson(response.body()!!.string()) + return listOf( + SChapter.create().apply { + url = "/en/comic/${data["slug"].asString}/reader/1" + name = "Chapter" + date_upload = DATE_FORMAT.parse(data["uploaded_at"].asString)?.time ?: 0 + chapter_number = 1f + } + ) + } + + // Pages + + override fun pageListRequest(chapter: SChapter): Request { + val slug = chapter.url.removePrefix("/en/comic/").removeSuffix("/reader/1") + return GET("$baseUrl/api/comics/$slug/images") + } + + override fun pageListParse(response: Response): List { + val data = gson.fromJson(response.body()!!.string()) + return data.getAsJsonArray("images").mapIndexed { i, it -> + Page(i, "/en/comic/${data["comic"]["slug"].asString}/reader/${it["page"].asInt}", it["source_url"].asString) + } + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used") + + // Filters + + private class SortFilter(sortPairs: List>) : Filter.Select("Sort By", sortPairs.map { it.first }.toTypedArray()) + private class OrderFilter(orderPairs: List>) : Filter.Select("Order By", orderPairs.map { it.first }.toTypedArray()) + private class DurationFilter(durationPairs: List>) : Filter.Select("Duration", durationPairs.map { it.first }.toTypedArray()) + private class AttributeFilter(name: String, val value: String) : Filter.CheckBox(name) + private class AttributesGroupFilter(attributePairs: List>) : Filter.Group("Attributes", attributePairs.map { AttributeFilter(it.first, it.second) }) + + private class CategoriesFilter : LookupFilter("Categories", "categories", "category") + private class TagsFilter : LookupFilter("Tags", "tags", "tag") + private class ArtistsFilter : LookupFilter("Artists", "artists", "artist") + private class GroupsFilter : LookupFilter("Groups", "groups", "group") + private class CharactersFilter : LookupFilter("Characters", "characters", "character") + private class ParodiesFilter : LookupFilter("Parodies", "parodies", "parody") + private class LanguagesFilter : LookupFilter("Other Languages", "languages", "language") + open class LookupFilter(name: String, val uri: String, val singularName: String) : Filter.Text(name) + + override fun getFilterList() = FilterList( + SortFilter(getSortPairs()), + OrderFilter(getOrderPairs()), + DurationFilter(getDurationPairs()), + Filter.Header("Separate terms with commas (,)"), + CategoriesFilter(), + TagsFilter(), + ArtistsFilter(), + GroupsFilter(), + CharactersFilter(), + ParodiesFilter(), + LanguagesFilter(), + AttributesGroupFilter(getAttributePairs()) + ) + + private fun getSortPairs() = listOf( + Pair("Upload Date", "uploaded_at"), + Pair("Title", "title"), + Pair("Pages", "pages"), + Pair("Favorites", "favorites"), + Pair("Popularity", "popularity") + ) + + private fun getOrderPairs() = listOf( + Pair("Descending", "desc"), + Pair("Ascending", "asc") + ) + + private fun getDurationPairs() = listOf( + Pair("Today", "day"), + Pair("This Week", "week"), + Pair("This Month", "month"), + Pair("This Year", "year"), + Pair("All Time", "all") + ) + + private fun getAttributePairs() = listOf( + Pair("Translated", "translated"), + Pair("Speechless", "speechless"), + Pair("Rewritten", "rewritten") + ) + + companion object { + @SuppressLint("SimpleDateFormat") + private val DATE_FORMAT = SimpleDateFormat("yyyy-dd-MM") + } +} diff --git a/src/all/hentaihand/src/eu/kanade/tachiyomi/extension/all/hentaihand/HentaiHandFactory.kt b/src/all/hentaihand/src/eu/kanade/tachiyomi/extension/all/hentaihand/HentaiHandFactory.kt new file mode 100644 index 000000000..eee5d1542 --- /dev/null +++ b/src/all/hentaihand/src/eu/kanade/tachiyomi/extension/all/hentaihand/HentaiHandFactory.kt @@ -0,0 +1,15 @@ +package eu.kanade.tachiyomi.extension.all.hentaihand + +import eu.kanade.tachiyomi.annotations.Nsfw +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +@Nsfw +class HentaiHandFactory : SourceFactory { + + override fun createSources(): List = listOf( + HentaiHand("en", 1), + HentaiHand("zh", 2), + HentaiHand("ja", 3) + ) +}