diff --git a/src/en/inkr/build.gradle b/src/en/inkr/build.gradle new file mode 100644 index 000000000..866aba37e --- /dev/null +++ b/src/en/inkr/build.gradle @@ -0,0 +1,16 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: INKR' + pkgNameSuffix = 'en.inkr' + extClass = '.INKR' + extVersionCode = 1 + libVersion = '1.2' +} + +dependencies { + compileOnly 'com.github.salomonbrys.kotson:kotson:2.5.0' +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/inkr/res/mipmap-hdpi/ic_launcher.png b/src/en/inkr/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..43925d0cf Binary files /dev/null and b/src/en/inkr/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/inkr/res/mipmap-mdpi/ic_launcher.png b/src/en/inkr/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..5ce837cbe Binary files /dev/null and b/src/en/inkr/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/inkr/res/mipmap-xhdpi/ic_launcher.png b/src/en/inkr/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..6b530ea4e Binary files /dev/null and b/src/en/inkr/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/inkr/res/mipmap-xxhdpi/ic_launcher.png b/src/en/inkr/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..0e0f3a788 Binary files /dev/null and b/src/en/inkr/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/inkr/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/inkr/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..e5999d8fd Binary files /dev/null and b/src/en/inkr/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/inkr/res/web_hi_res_512.png b/src/en/inkr/res/web_hi_res_512.png new file mode 100644 index 000000000..ed5b8330a Binary files /dev/null and b/src/en/inkr/res/web_hi_res_512.png differ diff --git a/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/INKR.kt b/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/INKR.kt new file mode 100644 index 000000000..cd6276699 --- /dev/null +++ b/src/en/inkr/src/eu/kanade/tachiyomi/extension/en/inkr/INKR.kt @@ -0,0 +1,351 @@ +package eu.kanade.tachiyomi.extension.en.inkr + +import com.github.salomonbrys.kotson.jsonObject +import com.github.salomonbrys.kotson.set +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +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.MediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import org.json.JSONObject +import rx.Observable +import java.util.ArrayList +import kotlin.experimental.and +import kotlin.experimental.xor + +/** + * INKR source - same old MR code, though + */ + +class INKR : HttpSource() { + + override val name = "INKR" + + override val baseUrl = "https://mangarock.com" + + private val apiUrl = "https://api.mangarockhd.com/query/android500" + + override val lang = "en" + + override val supportsLatest = true + + // Handles the page decoding + override val client: OkHttpClient = super.client.newBuilder().addInterceptor(fun(chain): Response { + val url = chain.request().url().toString() + val response = chain.proceed(chain.request()) + if (!url.endsWith(".mri")) return response + + val decoded: ByteArray = decodeMri(response) + val mediaType = MediaType.parse("image/webp") + val rb = ResponseBody.create(mediaType, decoded) + return response.newBuilder().body(rb).build() + }).build() + + override fun latestUpdatesRequest(page: Int) = GET("$apiUrl/mrs_latest") + + override fun latestUpdatesParse(response: Response): MangasPage { + val res = response.body()!!.string() + val list = getMangaListFromJson(res) + return getMangasPageFromJsonList(list) + } + + override fun popularMangaRequest(page: Int) = GET("$apiUrl/mrs_latest") + + override fun popularMangaParse(response: Response): MangasPage { + val res = response.body()!!.string() + val list = getMangaListFromJson(res) + return getMangasPageFromJsonList(sortByRank(list)) + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val jsonType = MediaType.parse("application/jsonType; charset=utf-8") + + // Filter + if (query.isBlank()) { + var status = "" + var orderBy = "" + val genres = jsonObject() + filters.forEach { filter -> + when (filter) { + is StatusFilter -> { + status = when (filter.state) { + Filter.TriState.STATE_INCLUDE -> "completed" + Filter.TriState.STATE_EXCLUDE -> "ongoing" + else -> "all" + } + } + is SortBy -> { + orderBy = filter.toUriPart() + } + is GenreList -> { + filter.state + .filter { it.state != Filter.TriState.STATE_IGNORE } + .forEach { genres[it.id] = it.state == Filter.TriState.STATE_INCLUDE } + } + } + } + + val body = RequestBody.create(jsonType, jsonObject( + "status" to status, + "genres" to genres, + "order" to orderBy + ).toString()) + return POST("$apiUrl/mrs_filter", headers, body) + } + + // Regular search + val body = RequestBody.create(jsonType, jsonObject( + "type" to "series", + "keywords" to query + ).toString()) + return POST("$apiUrl/mrs_search", headers, body) + } + + override fun searchMangaParse(response: Response): MangasPage { + val idArray = JSONObject(response.body()!!.string()).getJSONArray("data") + + val jsonType = MediaType.parse("application/jsonType; charset=utf-8") + val body = RequestBody.create(jsonType, idArray.toString()) + val metaRes = client.newCall(POST("https://api.mangarockhd.com/meta", headers, body)).execute().body()!!.string() + + val res = JSONObject(metaRes).getJSONObject("data") + val mangas = ArrayList(res.length()) + for (i in 0 until idArray.length()) { + val id = idArray.get(i).toString() + mangas.add(parseMangaJson(res.getJSONObject(id))) + } + return MangasPage(mangas, false) + } + + private fun getMangaListFromJson(json: String): List { + val arr = JSONObject(json).getJSONArray("data") + val mangaJson = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + mangaJson.add(arr.getJSONObject(i)) + } + return mangaJson + } + + private fun getMangasPageFromJsonList(arr: List): MangasPage { + val mangas = ArrayList(arr.size) + for (obj in arr) { + mangas.add(parseMangaJson(obj)) + } + return MangasPage(mangas, false) + } + + private fun parseMangaJson(obj: JSONObject): SManga { + return SManga.create().apply { + setUrlWithoutDomain("/manga/${obj.getString("oid")}") + title = obj.getString("name") + thumbnail_url = obj.getString("thumbnail") + status = if (obj.getBoolean("completed")) SManga.COMPLETED else SManga.ONGOING + } + } + private fun sortByRank(arr: List): List { + return arr.sortedBy { it.getInt("rank") } + } + + // Avoid directly overriding mangaDetailsRequest so that "Open in browser" action uses the + // "real" URL + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(getMangaApiRequest(manga)) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + // Always returns the "real" URL for the "Open in browser" action + override fun mangaDetailsRequest(manga: SManga): Request { + // Handle older entries with API URL ("/info?oid=mrs-series-...") + if (manga.url.startsWith("/info")) { + val oid = manga.url.substringAfterLast("=") + return GET("$baseUrl/manga/$oid", headers) + } + + return super.mangaDetailsRequest(manga) + } + + override fun chapterListRequest(manga: SManga) = getMangaApiRequest(manga) + + private fun getMangaApiRequest(manga: SManga): Request { + // Handle older entries with API URL ("/info?oid=mrs-series-...") + if (manga.url.startsWith("/info")) { + return GET("$apiUrl${manga.url}&Country=", headers) + } + + val oid = manga.url.substringAfterLast("/") + return GET("$apiUrl/info?oid=$oid&Country=", headers) + } + + override fun mangaDetailsParse(response: Response) = SManga.create().apply { + val obj = JSONObject(response.body()!!.string()).getJSONObject("data") + + title = obj.getString("name") + description = obj.getString("description") + + if (obj.isNull("authors")) { + artist = "" + author = "" + } else { + val people = obj.getJSONArray("authors") + val authors = ArrayList() + val artists = ArrayList() + for (i in 0 until people.length()) { + val person = people.getJSONObject(i) + when (person.getString("role")) { + "art" -> artists.add(person.getString("name")) + "story" -> authors.add(person.getString("name")) + } + } + artist = artists.sorted().joinToString(", ") + author = authors.sorted().joinToString(", ") + } + + val categories = obj.getJSONArray("rich_categories") + val genres = ArrayList(categories.length()) + for (i in 0 until categories.length()) { + genres.add(categories.getJSONObject(i).getString("name")) + } + genre = genres.sorted().joinToString(", ") + + status = if (obj.getBoolean("completed")) SManga.COMPLETED else SManga.ONGOING + thumbnail_url = obj.getString("thumbnail") + } + + override fun chapterListParse(response: Response): List { + val body = response.body()!!.string() + + if (body == "Manga is licensed") { + throw Exception("Manga has been removed from INKR, please migrate to another source") + } + + val obj = JSONObject(body).getJSONObject("data") + val chapters = ArrayList() + val arr = obj.getJSONArray("chapters") + // Iterate backwards to match website's sorting + for (i in arr.length() - 1 downTo 0) { + val chapter = arr.getJSONObject(i) + chapters.add(SChapter.create().apply { + name = chapter.getString("name") + date_upload = chapter.getString("updatedAt").toLong() * 1000 + url = "/pagesv3?oid=${chapter.getString("oid")}" + }) + } + return chapters + } + + override fun pageListRequest(chapter: SChapter) = GET(apiUrl + chapter.url, headers) + + override fun pageListParse(response: Response): List { + val obj = JSONObject(response.body()!!.string()).getJSONArray("data") + val pages = ArrayList() + for (i in 0 until obj.length()) { + pages.add(Page(i, "", obj.getJSONObject(i).getString("url"))) + } + return pages + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("This method should not be called!") + + // See drawWebpToCanvas function in the site's client.js file + // Extracted code: https://jsfiddle.net/6h2sLcs4/30/ + private fun decodeMri(response: Response): ByteArray { + val data = response.body()!!.bytes() + + // Decode file if it starts with "E" (space when XOR-ed later) + if (data[0] != 69.toByte()) return data + + // Reconstruct WEBP header + // Doc: https://developers.google.com/speed/webp/docs/riff_container#webp_file_header + val buffer = ByteArray(data.size + 15) + val size = data.size + 7 + buffer[0] = 82 // R + buffer[1] = 73 // I + buffer[2] = 70 // F + buffer[3] = 70 // F + buffer[4] = (255.toByte() and size.toByte()) + buffer[5] = (size ushr 8).toByte() and 255.toByte() + buffer[6] = (size ushr 16).toByte() and 255.toByte() + buffer[7] = (size ushr 24).toByte() and 255.toByte() + buffer[8] = 87 // W + buffer[9] = 69 // E + buffer[10] = 66 // B + buffer[11] = 80 // P + buffer[12] = 86 // V + buffer[13] = 80 // P + buffer[14] = 56 // 8 + + // Decrypt file content using XOR cipher with 101 as the key + val cipherKey = 101.toByte() + for (r in 0 until data.size) { + buffer[r + 15] = cipherKey xor data[r] + } + + return buffer + } + + private class StatusFilter : Filter.TriState("Completed") + + + private class SortBy : UriPartFilter("Sort by", arrayOf( + Pair("Name", "name"), + Pair("Rank", "rank") + )) + + private class Genre(name: String, val id: String) : Filter.TriState(name) + private class GenreList(genres: List) : Filter.Group("Genres", genres) + + override fun getFilterList() = FilterList( + // Search and filter don't work at the same time + Filter.Header("NOTE: Ignored if using text search!"), + Filter.Separator(), + StatusFilter(), + SortBy(), + GenreList(getGenreList()) + ) + + // [...document.querySelectorAll('._2DMqI .mdl-checkbox')].map(n => `Genre("${n.querySelector('.mdl-checkbox__label').innerText}", "${n.querySelector('input').dataset.oid}")`).sort().join(',\n') + // on https://mangarock.com/manga + private fun getGenreList() = listOf( + Genre("Action", "mrs-genre-304068"), + Genre("Adventure", "mrs-genre-304087"), + Genre("Comedy", "mrs-genre-304069"), + Genre("Drama", "mrs-genre-304177"), + Genre("Ecchi", "mrs-genre-304074"), + Genre("Fantasy", "mrs-genre-304089"), + Genre("Historical", "mrs-genre-304306"), + Genre("Horror", "mrs-genre-304259"), + Genre("Magic", "mrs-genre-304090"), + Genre("Martial Arts", "mrs-genre-304072"), + Genre("Mecha", "mrs-genre-304245"), + Genre("Military", "mrs-genre-304091"), + Genre("Music", "mrs-genre-304589"), + Genre("Psychological", "mrs-genre-304176"), + Genre("Romance", "mrs-genre-304073"), + Genre("School Life", "mrs-genre-304076"), + Genre("Shounen Ai", "mrs-genre-304307"), + Genre("Slice of Life", "mrs-genre-304195"), + Genre("Sports", "mrs-genre-304367"), + Genre("Supernatural", "mrs-genre-304067"), + Genre("Vampire", "mrs-genre-304765") + ) + + private open class UriPartFilter(displayName: String, val vals: Array>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second + } + +}