From 7586d7ff6179c20d810b8fec80609c0182dcb105 Mon Sep 17 00:00:00 2001 From: Jake Date: Mon, 14 Apr 2025 22:42:29 +0800 Subject: [PATCH] MangaHub (multisrc) - Rewrite Extension to Use the API Instead of Scraping (#8392) * updated chapter list parsing * More robust changes * Now uses HttpSource, updated logic to use API, and more * Fixed bugs, review changes, search and filter implementation * Address some PR comments * Review changes, improved API refresh logic, added pref for chapter titles --------- Co-authored-by: Vetle Ledaal --- lib-multisrc/mangahub/build.gradle.kts | 2 +- .../tachiyomi/multisrc/mangahub/MangaHub.kt | 572 ++++++++++-------- .../multisrc/mangahub/MangaHubDto.kt | 94 +++ .../multisrc/mangahub/MangaHubQueries.kt | 83 ++- 4 files changed, 461 insertions(+), 290 deletions(-) create mode 100644 lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubDto.kt diff --git a/lib-multisrc/mangahub/build.gradle.kts b/lib-multisrc/mangahub/build.gradle.kts index 99409b7e6..722908d9f 100644 --- a/lib-multisrc/mangahub/build.gradle.kts +++ b/lib-multisrc/mangahub/build.gradle.kts @@ -2,7 +2,7 @@ plugins { id("lib-multisrc") } -baseVersionCode = 31 +baseVersionCode = 32 dependencies { api(project(":lib:randomua")) diff --git a/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHub.kt b/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHub.kt index f6e237460..efa8bc9ef 100644 --- a/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHub.kt +++ b/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHub.kt @@ -1,37 +1,38 @@ package eu.kanade.tachiyomi.multisrc.mangahub +import android.content.SharedPreferences +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat import eu.kanade.tachiyomi.lib.randomua.UserAgentType import eu.kanade.tachiyomi.lib.randomua.setRandomUserAgent import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.ConfigurableSource import eu.kanade.tachiyomi.source.model.Filter import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga -import eu.kanade.tachiyomi.source.online.ParsedHttpSource -import eu.kanade.tachiyomi.util.asJsoup -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.json.Json +import eu.kanade.tachiyomi.source.online.HttpSource +import keiyoushi.utils.getPreferencesLazy +import keiyoushi.utils.parseAs +import keiyoushi.utils.tryParse import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import kotlinx.serialization.json.putJsonObject import okhttp3.Cookie import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response -import org.jsoup.nodes.Document -import org.jsoup.nodes.Element import rx.Observable -import uy.kohesive.injekt.injectLazy import java.io.IOException -import java.text.ParseException +import java.net.URLEncoder import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -41,21 +42,30 @@ abstract class MangaHub( final override val baseUrl: String, override val lang: String, private val mangaSource: String, - private val dateFormat: SimpleDateFormat = SimpleDateFormat("MM-dd-yyyy", Locale.US), -) : ParsedHttpSource() { + private val dateFormat: SimpleDateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH), +) : HttpSource(), ConfigurableSource { override val supportsLatest = true - private var baseApiUrl = "https://api.mghcdn.com" - private var baseCdnUrl = "https://imgx.mghcdn.com" + private val baseApiUrl = "https://api.mghcdn.com" + private val baseCdnUrl = "https://imgx.mghcdn.com" + private val baseThumbCdnUrl = "https://thumb.mghcdn.com" private val regex = Regex("mhub_access=([^;]+)") + private val preferences: SharedPreferences by getPreferencesLazy() + + private fun SharedPreferences.getUseGenericTitlePref(): Boolean = getBoolean( + PREF_USE_GENERIC_TITLE, + false, + ) + override val client: OkHttpClient = network.cloudflareClient.newBuilder() .setRandomUserAgent( userAgentType = UserAgentType.DESKTOP, filterInclude = listOf("chrome"), ) .addInterceptor(::apiAuthInterceptor) + .addInterceptor(::graphQLApiInterceptor) .rateLimit(1) .build() @@ -69,7 +79,26 @@ abstract class MangaHub( .add("Sec-Fetch-Site", "same-origin") .add("Upgrade-Insecure-Requests", "1") - open val json: Json by injectLazy() + private fun postRequestGraphQL(query: String): Request { + val requestHeaders = headersBuilder() + .set("Accept", "application/json") + .set("Content-Type", "application/json") + .set("Origin", baseUrl) + .set("Sec-Fetch-Dest", "empty") + .set("Sec-Fetch-Mode", "cors") + .set("Sec-Fetch-Site", "cross-site") + .removeAll("Upgrade-Insecure-Requests") + .build() + + val body = buildJsonObject { + put("query", query) + } + + return POST("$baseApiUrl/graphql", requestHeaders, body.toString().toRequestBody()) + .newBuilder() + .tag(GraphQLTag()) + .build() + } private fun apiAuthInterceptor(chain: Interceptor.Chain): Response { val originalRequest = chain.request() @@ -90,40 +119,92 @@ abstract class MangaHub( return chain.proceed(request) } - private fun refreshApiKey(chapter: SChapter) { - val slug = "$baseUrl${chapter.url}" - .toHttpUrlOrNull() - ?.pathSegments - ?.get(1) + private fun graphQLApiInterceptor(chain: Interceptor.Chain): Response { + val request = chain.request() - val url = if (slug != null) { - "$baseUrl/manga/$slug".toHttpUrl() - } else { - baseUrl.toHttpUrl() + // We won't intercept non-graphql requests (like image retrieval) + if (!request.hasGraphQLTag()) { + return chain.proceed(request) } + val response = chain.proceed(request) + + // We don't care about the data, only the possible error associated with it + // If we encounter an error, we'll intercept it and throw an error for app to catch + val apiResponse = response.peekBody(Long.MAX_VALUE).string().parseAs() + if (apiResponse.errors != null) { + response.close() // Avoid leaks + val errors = apiResponse.errors.joinToString("\n") { it.message } + throw IOException(errors) + } + + // Everything works fine + return response + } + + private fun Request.hasGraphQLTag(): Boolean { + return this.tag() is GraphQLTag + } + + private fun refreshApiKey(chapter: SChapter) { + val now = Calendar.getInstance().time.time + + val url = "$baseUrl/chapter${chapter.url}".toHttpUrl() val oldKey = client.cookieJar .loadForRequest(baseUrl.toHttpUrl()) .firstOrNull { it.name == "mhub_access" && it.value.isNotEmpty() }?.value + // With the recent changes on how refresh API token works, we are now apparently required to have + // a cookie for recently when requesting for a new one. Not having this will result in a hit or miss. + val recently = buildJsonObject { + putJsonObject((now - (0..3600).random()).toString()) { + put("mangaID", (1..42_000).random()) + put("number", (1..20).random()) + } + }.toString() + + val recentlyCookie = Cookie.Builder() + .domain(url.host) + .name("recently") + .value(URLEncoder.encode(recently, "utf-8")) + .expiresAt(now + 2 * 60 * 60 * 24 * 31) // +2 months + .build() + for (i in 1..2) { // Clear key cookie val cookie = Cookie.parse(url, "mhub_access=; Max-Age=0; Path=/")!! - client.cookieJar.saveFromResponse(url, listOf(cookie)) + client.cookieJar.saveFromResponse(url, listOf(cookie, recentlyCookie)) // We try requesting again with param if the first one fails val query = if (i == 2) "?reloadKey=1" else "" try { - val response = client.newCall(GET("$url$query", headers)).execute() + val response = client.newCall( + GET( + "$url$query", + headers.newBuilder() + .set("Referer", "$baseUrl/manga/${url.pathSegments[1]}") + .build(), + ), + ).execute() val returnedKey = response.headers["set-cookie"]?.let { regex.find(it)?.groupValues?.get(1) } response.close() // Avoid potential resource leaks - if (returnedKey != oldKey) break; // Break out of loop since we got an allegedly valid API key + if (returnedKey != oldKey) break // Break out of loop since we got an allegedly valid API key } catch (_: IOException) { throw IOException("An error occurred while obtaining a new API key") // Show error } } + + // Sometimes, the new API key is still invalid. To ensure that the token will be fresh and available to use, + // we have to mimic how the browser site works. To put it simply, we will send a GET request that indicates what + // manga and chapter were browsing. If this succeeded, the API key that we use will be revalidated (assuming that we got an expired one.) + // We first need to obtain our public IP first since it is required as a query. + val ipRequest = client.newCall(GET("https://api.ipify.org?format=json")).execute() + val ip = ipRequest.parseAs().ip + + // We'll log our action to the site to revalidate the API key in case we got an expired one + client.newCall(GET("$baseUrl/action/logHistory2/${url.pathSegments[1]}/${chapter.chapter_number}?browserID=$ip")).execute() } data class SMangaDTO( @@ -133,35 +214,36 @@ abstract class MangaHub( val signature: String, ) - private fun Element.toSignature(): String { - val author = this.select("small").text() - val chNum = this.select(".col-sm-6 a:contains(#)").text() - val genres = this.select(".genre-label").joinToString { it.text() } + private fun ApiMangaSearchItem.toSignature(): String { + val author = this.author + val chNum = this.latestChapter + val genres = this.genres return author + chNum + genres } - // popular - override fun popularMangaRequest(page: Int): Request { - return GET("$baseUrl/popular/page/$page", headers) + private fun mangaRequest(page: Int, order: String): Request { + return postRequestGraphQL(searchQuery(mangaSource, "", "all", order, page)) } + // popular + override fun popularMangaRequest(page: Int): Request = mangaRequest(page, "POPULAR") + // often enough there will be nearly identical entries with slightly different // titles, URLs, and image names. in order to cut these "duplicates" down, // assign a "signature" based on author name, chapter number, and genres // if all of those are the same, then it it's the same manga override fun popularMangaParse(response: Response): MangasPage { - val doc = response.asJsoup() + val mangaList = response.parseAs() - val mangas = doc.select(popularMangaSelector()) - .map { - SMangaDTO( - it.select("h4 a").attr("abs:href"), - it.select("h4 a").text(), - it.select("img").attr("abs:src"), - it.toSignature(), - ) - } + val mangas = mangaList.data.search.rows.map { + SMangaDTO( + "$baseUrl/manga/${it.slug}", + it.title, + "$baseThumbCdnUrl/${it.image}", + it.toSignature(), + ) + } .distinctBy { it.signature } .map { SManga.create().apply { @@ -170,198 +252,126 @@ abstract class MangaHub( thumbnail_url = it.thumbnailUrl } } - return MangasPage(mangas, doc.select(popularMangaNextPageSelector()).isNotEmpty()) + + // Entries have a max of 30 per request + return MangasPage(mangas, mangaList.data.search.rows.count() == 30) } - override fun popularMangaSelector() = ".col-sm-6:not(:has(a:contains(Yaoi)))" - - override fun popularMangaFromElement(element: Element): SManga { - throw UnsupportedOperationException() - } - - override fun popularMangaNextPageSelector() = "ul.pager li.next > a" - // latest override fun latestUpdatesRequest(page: Int): Request { - return GET("$baseUrl/updates/page/$page", headers) + return mangaRequest(page, "LATEST") } override fun latestUpdatesParse(response: Response): MangasPage { return popularMangaParse(response) } - override fun latestUpdatesSelector() = popularMangaSelector() - - override fun latestUpdatesFromElement(element: Element): SManga { - throw UnsupportedOperationException() - } - - override fun latestUpdatesNextPageSelector() = popularMangaNextPageSelector() - // search override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { - val url = "$baseUrl/search/page/$page".toHttpUrl().newBuilder() - url.addQueryParameter("q", query) + var order = "POPULAR" + var genres = "all" + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> when (filter) { is OrderBy -> { - val order = filter.values[filter.state] - url.addQueryParameter("order", order.key) + order = filter.values[filter.state].key } is GenreList -> { - val genre = filter.values[filter.state] - url.addQueryParameter("genre", genre.key) + genres = filter.included.joinToString(",").takeIf { it.isNotBlank() } ?: "all" } else -> {} } } - return GET(url.build(), headers) - } - override fun searchMangaSelector() = popularMangaSelector() - - override fun searchMangaFromElement(element: Element): SManga { - throw UnsupportedOperationException() + return postRequestGraphQL(searchQuery(mangaSource, query, genres, order, page)) } override fun searchMangaParse(response: Response): MangasPage { return popularMangaParse(response) } - override fun searchMangaNextPageSelector() = popularMangaNextPageSelector() - // manga details - override fun mangaDetailsParse(document: Document): SManga { - val manga = SManga.create() - manga.title = document.select(".breadcrumb .active span").text() - manga.author = document.select("div:has(h1) span:contains(Author) + span").first()?.text() - manga.artist = document.select("div:has(h1) span:contains(Artist) + span").first()?.text() - manga.genre = document.select(".row p a").joinToString { it.text() } - manga.description = document.select(".tab-content p").first()?.text() - manga.thumbnail_url = document.select("img.img-responsive").first() - ?.attr("src") + override fun mangaDetailsRequest(manga: SManga): Request { + return postRequestGraphQL(mangaDetailsQuery(mangaSource, manga.url.removePrefix("/manga/"))) + } - document.select("div:has(h1) span:contains(Status) + span").first()?.text()?.also { statusText -> - when { - statusText.contains("ongoing", true) -> manga.status = SManga.ONGOING - statusText.contains("completed", true) -> manga.status = SManga.COMPLETED - else -> manga.status = SManga.UNKNOWN + override fun mangaDetailsParse(response: Response): SManga { + val rawManga = response.parseAs() + + return SManga.create().apply { + title = rawManga.data.manga.title!! + author = rawManga.data.manga.author + artist = rawManga.data.manga.artist + genre = rawManga.data.manga.genres + thumbnail_url = "$baseThumbCdnUrl/${rawManga.data.manga.image}" + status = when (rawManga.data.manga.status) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN } - } - // add alternative name to manga description - document.select("h1 small").firstOrNull()?.ownText()?.let { alternativeName -> - if (alternativeName.isNotBlank()) { - manga.description = manga.description.orEmpty().let { - if (it.isBlank()) { - "Alternative Name: $alternativeName" - } else { - "$it\n\nAlternative Name: $alternativeName" - } + description = buildString { + rawManga.data.manga.description?.let(::append) + + // Add alternative title + val altTitle = rawManga.data.manga.alternativeTitle + if (!altTitle.isNullOrBlank()) { + if (isNotBlank()) append("\n\n") + append("Alternative Name: $altTitle") } } } - - return manga } - // chapters + override fun getMangaUrl(manga: SManga): String = "$baseUrl${manga.url}" + + // Chapters + override fun chapterListRequest(manga: SManga): Request { + return postRequestGraphQL(mangaChapterListQuery(mangaSource, manga.url.removePrefix("/manga/"))) + } + override fun chapterListParse(response: Response): List { - val document = response.asJsoup() - val head = document.head() - return document.select(chapterListSelector()).map { chapterFromElement(it, head) } + val chapterList = response.parseAs() + val useGenericTitle = preferences.getUseGenericTitlePref() + + return chapterList.data.manga.chapters!!.map { + SChapter.create().apply { + val numberString = "${if (it.number % 1 == 0f) it.number.toInt() else it.number}" + + name = if (!useGenericTitle) { + generateChapterName(it.title.trim().replace("\n", " "), numberString) + } else { + generateGenericChapterName(numberString) + } + + url = "/${chapterList.data.manga.slug}/chapter-${it.number}" + chapter_number = it.number + date_upload = dateFormat.tryParse(it.date) + } + }.reversed() // The response is sorted in ASC format so we need to reverse it } - override fun chapterListSelector() = ".tab-content ul li" - - private fun chapterFromElement(element: Element, head: Element): SChapter { - val chapter = SChapter.create() - val potentialLinks = element.select("a[href*='$baseUrl/chapter/']") - var visibleLink = "" - potentialLinks.forEach { a -> - val className = a.className() - val styles = head.select("style").html() - if (!styles.contains(".$className { display:none; }")) { - visibleLink = a.attr("href") - return@forEach - } + private fun generateChapterName(title: String, number: String): String { + return if (title.contains(number)) { + title + } else if (title.isNotBlank()) { + "Chapter $number - $title" + } else { + generateGenericChapterName(number) } - chapter.setUrlWithoutDomain(visibleLink) - chapter.name = chapter.url.trimEnd('/').substringAfterLast('/').replace('-', ' ') - chapter.date_upload = element.select("small.UovLc").first()?.text()?.let { parseChapterDate(it) } ?: 0 - return chapter } - override fun chapterFromElement(element: Element): SChapter { - throw UnsupportedOperationException() + private fun generateGenericChapterName(number: String): String { + return "Chapter $number" } - private fun parseChapterDate(date: String): Long { - val now = Calendar.getInstance().apply { - set(Calendar.HOUR_OF_DAY, 0) - set(Calendar.MINUTE, 0) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - var parsedDate = 0L - when { - "just now" in date || "less than an hour" in date -> { - parsedDate = now.timeInMillis - } - // parses: "1 hour ago" and "2 hours ago" - "hour" in date -> { - val hours = date.replaceAfter(" ", "").trim().toInt() - parsedDate = now.apply { add(Calendar.HOUR, -hours) }.timeInMillis - } - // parses: "Yesterday" and "2 days ago" - "day" in date -> { - val days = date.replace("days ago", "").trim().toIntOrNull() ?: 1 - parsedDate = now.apply { add(Calendar.DAY_OF_YEAR, -days) }.timeInMillis - } - // parses: "2 weeks ago" - "weeks" in date -> { - val weeks = date.replace("weeks ago", "").trim().toInt() - parsedDate = now.apply { add(Calendar.WEEK_OF_YEAR, -weeks) }.timeInMillis - } - // parses: "12-20-2019" and defaults everything that wasn't taken into account to 0 - else -> { - try { - parsedDate = dateFormat.parse(date)?.time ?: 0L - } catch (e: ParseException) { /*nothing to do, parsedDate is initialized with 0L*/ } - } - } - return parsedDate - } + override fun getChapterUrl(chapter: SChapter): String = "$baseUrl/chapter${chapter.url}" - // pages + // Pages override fun pageListRequest(chapter: SChapter): Request { - val body = buildJsonObject { - put("query", PAGES_QUERY) - put( - "variables", - buildJsonObject { - val chapterUrl = chapter.url.split("/") + val chapterUrl = chapter.url.split("/") - put("mangaSource", mangaSource) - put("slug", chapterUrl[2]) - put("number", chapterUrl[3].substringAfter("-").toFloat()) - }, - ) - } - .toString() - .toRequestBody() - - val newHeaders = headersBuilder() - .set("Accept", "application/json") - .set("Content-Type", "application/json") - .set("Origin", baseUrl) - .set("Sec-Fetch-Dest", "empty") - .set("Sec-Fetch-Mode", "cors") - .set("Sec-Fetch-Site", "cross-site") - .removeAll("Upgrade-Insecure-Requests") - .build() - - return POST("$baseApiUrl/graphql", newHeaders, body) + return postRequestGraphQL(pagesQuery(mangaSource, chapterUrl[1], chapterUrl[2].substringAfter("-").toFloat())) } override fun fetchPageList(chapter: SChapter): Observable> = @@ -369,22 +379,12 @@ abstract class MangaHub( .doOnError { refreshApiKey(chapter) } .retry(1) - override fun pageListParse(document: Document): List = throw UnsupportedOperationException() override fun pageListParse(response: Response): List { - val chapterObject = json.decodeFromString(response.body.string()) + val chapterObject = response.parseAs() + val pages = chapterObject.data.chapter.pages.parseAs() - if (chapterObject.data?.chapter == null) { - if (chapterObject.errors != null) { - val errors = chapterObject.errors.joinToString("\n") { it.message } - throw Exception(errors) - } - throw Exception("Unknown error while processing pages") - } - - val pages = json.decodeFromString(chapterObject.data.chapter.pages) - - return pages.i.mapIndexed { i, page -> - Page(i, "", "$baseCdnUrl/${pages.p}$page") + return pages.images.mapIndexed { i, page -> + Page(i, "", "$baseCdnUrl/${pages.page}$page") } } @@ -401,10 +401,14 @@ abstract class MangaHub( return GET(page.url, newHeaders) } - override fun imageUrlParse(document: Document): String = throw UnsupportedOperationException() + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() // filters - private class Genre(title: String, val key: String) : Filter.TriState(title) { + private class Genre(title: String, val key: String) : Filter.CheckBox(title) { + fun getGenreKey(): String { + return key + } + override fun toString(): String { return name } @@ -417,11 +421,14 @@ abstract class MangaHub( } private class OrderBy(orders: Array) : Filter.Select("Order", orders, 0) - private class GenreList(genres: Array) : Filter.Select("Genres", genres, 0) + private class GenreList(genres: List) : Filter.Group("Genres", genres) { + val included: List + get() = state.filter { it.state }.map { it.getGenreKey() } + } override fun getFilterList() = FilterList( - OrderBy(orderBy), GenreList(genres), + OrderBy(orderBy), ) private val orderBy = arrayOf( @@ -432,70 +439,119 @@ abstract class MangaHub( Order("Completed", "COMPLETED"), ) - private val genres = arrayOf( - Genre("All Genres", "all"), - Genre("[no chapters]", "no-chapters"), - Genre("4-Koma", "4-koma"), + private val genres = listOf( Genre("Action", "action"), Genre("Adventure", "adventure"), - Genre("Award Winning", "award-winning"), Genre("Comedy", "comedy"), - Genre("Cooking", "cooking"), - Genre("Crime", "crime"), - Genre("Demons", "demons"), - Genre("Doujinshi", "doujinshi"), + Genre("Adult", "adult"), Genre("Drama", "drama"), - Genre("Ecchi", "ecchi"), - Genre("Fantasy", "fantasy"), - Genre("Food", "food"), - Genre("Game", "game"), - Genre("Gender bender", "gender-bender"), - Genre("Harem", "harem"), Genre("Historical", "historical"), - Genre("Horror", "horror"), - Genre("Isekai", "isekai"), - Genre("Josei", "josei"), - Genre("Kids", "kids"), - Genre("Magic", "magic"), - Genre("Magical Girls", "magical-girls"), - Genre("Manhua", "manhua"), + Genre("Martial Arts", "martial-arts"), + Genre("Romance", "romance"), + Genre("Ecchi", "ecchi"), + Genre("Supernatural", "supernatural"), + Genre("Webtoons", "webtoons"), Genre("Manhwa", "manhwa"), - Genre("Martial arts", "martial-arts"), + Genre("Fantasy", "fantasy"), + Genre("Harem", "harem"), + Genre("Shounen", "shounen"), + Genre("Manhua", "manhua"), Genre("Mature", "mature"), + Genre("Seinen", "seinen"), + Genre("Sports", "sports"), + Genre("School Life", "school-life"), + Genre("Smut", "smut"), + Genre("Mystery", "mystery"), + Genre("Psychological", "psychological"), + Genre("Shounen ai", "shounen-ai"), + Genre("Slice of life", "slice-of-life"), + Genre("Shoujo ai", "shoujo-ai"), + Genre("Cooking", "cooking"), + Genre("Horror", "horror"), + Genre("Tragedy", "tragedy"), + Genre("Doujinshi", "doujinshi"), + Genre("Sci-Fi", "sci-fi"), + Genre("Yuri", "yuri"), + Genre("Yaoi", "yaoi"), + Genre("Shoujo", "shoujo"), + Genre("Gender bender", "gender-bender"), + Genre("Josei", "josei"), Genre("Mecha", "mecha"), Genre("Medical", "medical"), - Genre("Military", "military"), + Genre("Magic", "magic"), + Genre("4-Koma", "4-koma"), Genre("Music", "music"), - Genre("Mystery", "mystery"), - Genre("One shot", "one-shot"), - Genre("Oneshot", "oneshot"), - Genre("Parody", "parody"), - Genre("Police", "police"), - Genre("Psychological", "psychological"), - Genre("Romance", "romance"), - Genre("School life", "school-life"), - Genre("Sci fi", "sci-fi"), - Genre("Seinen", "seinen"), - Genre("Shotacon", "shotacon"), - Genre("Shoujo", "shoujo"), - Genre("Shoujo ai", "shoujo-ai"), - Genre("Shoujoai", "shoujoai"), - Genre("Shounen", "shounen"), - Genre("Shounen ai", "shounen-ai"), - Genre("Shounenai", "shounenai"), - Genre("Slice of life", "slice-of-life"), - Genre("Smut", "smut"), - Genre("Space", "space"), - Genre("Sports", "sports"), - Genre("Super Power", "super-power"), - Genre("Superhero", "superhero"), - Genre("Supernatural", "supernatural"), - Genre("Thriller", "thriller"), - Genre("Tragedy", "tragedy"), - Genre("Vampire", "vampire"), Genre("Webtoon", "webtoon"), - Genre("Webtoons", "webtoons"), + Genre("Isekai", "isekai"), + Genre("Game", "game"), + Genre("Award Winning", "award-winning"), + Genre("Oneshot", "oneshot"), + Genre("Demons", "demons"), + Genre("Military", "military"), + Genre("Police", "police"), + Genre("Super Power", "super-power"), + Genre("Food", "food"), + Genre("Kids", "kids"), + Genre("Magical Girls", "magical-girls"), Genre("Wuxia", "wuxia"), - Genre("Yuri", "yuri"), + Genre("Superhero", "superhero"), + Genre("Thriller", "thriller"), + Genre("Crime", "crime"), + Genre("Philosophical", "philosophical"), + Genre("Adaptation", "adaptation"), + Genre("Full Color", "full-color"), + Genre("Crossdressing", "crossdressing"), + Genre("Reincarnation", "reincarnation"), + Genre("Manga", "manga"), + Genre("Cartoon", "cartoon"), + Genre("Survival", "survival"), + Genre("Comic", "comic"), + Genre("English", "english"), + Genre("Harlequin", "harlequin"), + Genre("Time Travel", "time-travel"), + Genre("Traditional Games", "traditional-games"), + Genre("Reverse Harem", "reverse-harem"), + Genre("Animals", "animals"), + Genre("Aliens", "aliens"), + Genre("Loli", "loli"), + Genre("Video Games", "video-games"), + Genre("Monsters", "monsters"), + Genre("Office Workers", "office-workers"), + Genre("system", "system"), + Genre("Villainess", "villainess"), + Genre("Zombies", "zombies"), + Genre("Vampires", "vampires"), + Genre("Violence", "violence"), + Genre("Monster Girls", "monster-girls"), + Genre("Anthology", "anthology"), + Genre("Ghosts", "ghosts"), + Genre("Delinquents", "delinquents"), + Genre("Post-Apocalyptic", "post-apocalyptic"), + Genre("Xianxia", "xianxia"), + Genre("Xuanhuan", "xuanhuan"), + Genre("R-18", "r-18"), + Genre("Cultivation", "cultivation"), + Genre("Rebirth", "rebirth"), + Genre("Gore", "gore"), + Genre("Russian", "russian"), + Genre("Samurai", "samurai"), + Genre("Ninja", "ninja"), + Genre("Revenge", "revenge"), + Genre("Cheat Systems", "cheat-systems"), + Genre("Dungeons", "dungeons"), + Genre("Overpowered", "overpowered"), ) + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + SwitchPreferenceCompat(screen.context).apply { + key = PREF_USE_GENERIC_TITLE + title = "Use generic title" + summary = "Use generic chapter title (\"Chapter 'x'\") instead of the given one.\nNote: May require manga entry to be refreshed." + setDefaultValue(false) + }.let(screen::addPreference) + } + + companion object { + private const val PREF_USE_GENERIC_TITLE = "pref_use_generic_title" + } } diff --git a/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubDto.kt b/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubDto.kt new file mode 100644 index 000000000..f94ab4eec --- /dev/null +++ b/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubDto.kt @@ -0,0 +1,94 @@ +package eu.kanade.tachiyomi.multisrc.mangahub + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias ApiChapterPagesResponse = ApiResponse +typealias ApiSearchResponse = ApiResponse +typealias ApiMangaDetailsResponse = ApiResponse + +// Base classes +@Serializable +class ApiResponse( + val data: T, +) + +@Serializable +class ApiResponseError( + val errors: List?, +) + +@Serializable +class ApiErrorMessages( + val message: String, +) + +@Serializable +class PublicIPResponse( + val ip: String, +) + +// Chapter metadata (pages) +@Serializable +class ApiChapterData( + val chapter: ApiChapter, +) + +@Serializable +class ApiChapter( + val pages: String, +) + +@Serializable +class ApiChapterPages( + @SerialName("p") val page: String, + @SerialName("i") val images: List, +) + +// Search, Popular, Latest +@Serializable +class ApiSearchObject( + val search: ApiSearchResults, +) + +@Serializable +class ApiSearchResults( + val rows: List, +) + +@Serializable +class ApiMangaSearchItem( + val title: String, + val slug: String, + val image: String, + val author: String, + val latestChapter: Float, + val genres: String, +) + +// Manga Details, Chapters +@Serializable +class ApiMangaObject( + val manga: ApiMangaData, +) + +@Serializable +class ApiMangaData( + val title: String?, + val status: String?, + val image: String?, + val author: String?, + val artist: String?, + val genres: String?, + val description: String?, + val alternativeTitle: String?, + val slug: String?, + val chapters: List?, +) + +@Serializable +class ApiMangaChapterList( + val number: Float, + val title: String, + val date: String, +) diff --git a/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubQueries.kt b/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubQueries.kt index 891a90883..68139cac9 100644 --- a/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubQueries.kt +++ b/lib-multisrc/mangahub/src/eu/kanade/tachiyomi/multisrc/mangahub/MangaHubQueries.kt @@ -1,42 +1,63 @@ package eu.kanade.tachiyomi.multisrc.mangahub -import kotlinx.serialization.Serializable +class GraphQLTag -private fun buildQuery(queryAction: () -> String) = queryAction().replace("%", "$") - -val PAGES_QUERY = buildQuery { +val searchQuery = { mangaSource: String, query: String, genre: String, order: String, page: Int -> """ - query(%mangaSource: MangaSource, %slug: String!, %number: Float!) { - chapter(x: %mangaSource, slug: %slug, number: %number) { - pages + { + search(x: $mangaSource, q: "$query", genre: "$genre", mod: $order, offset: ${(page - 1) * 30}) { + rows { + title, + author, + slug, + image, + genres, + latestChapter } } + } """.trimIndent() } -@Serializable -data class ApiErrorMessages( - val message: String, -) +val mangaDetailsQuery = { mangaSource: String, slug: String -> + """ + { + manga(x: $mangaSource, slug: "$slug") { + title, + slug, + status, + image, + author, + artist, + genres, + description, + alternativeTitle + } + } + """.trimIndent() +} -@Serializable -data class ApiChapterPagesResponse( - val data: ApiChapterData?, - val errors: List?, -) +val mangaChapterListQuery = { mangaSource: String, slug: String -> + """ + { + manga(x: $mangaSource, slug: "$slug") { + slug, + chapters { + number, + title, + date + } + } + } + """.trimIndent() +} -@Serializable -data class ApiChapterData( - val chapter: ApiChapter?, -) - -@Serializable -data class ApiChapter( - val pages: String, -) - -@Serializable -data class ApiChapterPages( - val p: String, - val i: List, -) +val pagesQuery = { mangaSource: String, slug: String, number: Float -> + """ + { + chapter(x: $mangaSource, slug: "$slug", number: $number) { + pages + } + } + """.trimIndent() +}