From eec1236b8b5904b46d79bb716e29f073c6fe9b34 Mon Sep 17 00:00:00 2001 From: MediocreLegion <162182192+MediocreLegion@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:59:13 -0300 Subject: [PATCH] fix(delegate): migrate NH to the v2 api (#1581) * fix(delegate): migrate NH to the v2 api * remove extra comment * remove redundant data * linting * Code cleanup --------- Co-authored-by: Jobobby04 --- .../tachiyomi/source/online/all/NHentai.kt | 82 +++++++++---------- .../adapters/NHentaiDescriptionAdapter.kt | 4 +- .../metadata/NHentaiSearchMetadata.kt | 30 ++----- 3 files changed, 47 insertions(+), 69 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt index 7747e4cee..b42c34553 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt @@ -29,6 +29,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.CacheControl import okhttp3.Response +import tachiyomi.core.common.util.lang.withIOContext class NHentai(delegate: HttpSource, val context: Context) : DelegatedHttpSource(delegate), @@ -70,12 +71,8 @@ class NHentai(delegate: HttpSource, val context: Context) : } override suspend fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) { - val body = input.body.string() - val server = MEDIA_SERVER_REGEX.find(body)?.groupValues?.get(1)?.toInt() ?: 1 - val json = GALLERY_JSON_REGEX.find(body)!!.groupValues[1].replace( - UNICODE_ESCAPE_REGEX, - ) { it.groupValues[1].toInt(radix = 16).toChar().toString() } - val jsonResponse = jsonParser.decodeFromString(json) + if (nhConfig == null) getNhConfig() + val jsonResponse = jsonParser.decodeFromString(input.body.string()) with(metadata) { nhId = jsonResponse.id @@ -86,8 +83,6 @@ class NHentai(delegate: HttpSource, val context: Context) : mediaId = jsonResponse.mediaId - mediaServer = server - jsonResponse.title?.let { title -> japaneseTitle = title.japanese shortTitle = title.pretty @@ -96,15 +91,11 @@ class NHentai(delegate: HttpSource, val context: Context) : preferredTitle = this@NHentai.preferredTitle - jsonResponse.images?.let { images -> - coverImageType = images.cover?.type - images.pages.mapNotNull { - it.type - }.let { - pageImageTypes = it - } - thumbnailImageType = images.thumbnail?.type - } + coverImageUrl = + jsonResponse.cover?.path?.let { "$thumbServer/$it" } + ?: jsonResponse.thumbnail?.path?.let { "$thumbServer/$it" } + + pageImagePreviewUrls = jsonResponse.pages.mapNotNull { it.thumbnail } scanlator = jsonResponse.scanlator?.trimOrNull() @@ -125,13 +116,22 @@ class NHentai(delegate: HttpSource, val context: Context) : } } + @Serializable + data class JsonConfig( + @SerialName("image_servers") + val imageServers: List = emptyList(), + @SerialName("thumb_servers") + val thumbServers: List = emptyList(), + ) + @Serializable data class JsonResponse( val id: Long, @SerialName("media_id") val mediaId: String? = null, val title: JsonTitle? = null, - val images: JsonImages? = null, + val cover: JsonPage? = null, + val thumbnail: JsonPage? = null, val scanlator: String? = null, @SerialName("upload_date") val uploadDate: Long? = null, @@ -140,6 +140,7 @@ class NHentai(delegate: HttpSource, val context: Context) : val numPages: Int? = null, @SerialName("num_favorites") val numFavorites: Long? = null, + val pages: List = emptyList(), ) @Serializable @@ -149,21 +150,12 @@ class NHentai(delegate: HttpSource, val context: Context) : val pretty: String? = null, ) - @Serializable - data class JsonImages( - val pages: List = emptyList(), - val cover: JsonPage? = null, - val thumbnail: JsonPage? = null, - ) - @Serializable data class JsonPage( - @SerialName("t") - val type: String? = null, - @SerialName("w") + val path: String? = null, val width: Long? = null, - @SerialName("h") val height: Long? = null, + val thumbnail: String? = null, ) @Serializable @@ -188,15 +180,16 @@ class NHentai(delegate: HttpSource, val context: Context) : } override suspend fun getPagePreviewList(manga: SManga, chapters: List, page: Int): PagePreviewPage { + if (nhConfig == null) getNhConfig() val metadata = fetchOrLoadMetadata(manga.id()) { client.newCall(mangaDetailsRequest(manga)).awaitSuccess() } return PagePreviewPage( page, - metadata.pageImageTypes.mapIndexed { index, s -> + metadata.pageImagePreviewUrls.mapIndexed { index, path -> PagePreviewInfo( index + 1, - imageUrl = thumbnailUrlFromType(metadata.mediaId!!, metadata.mediaServer ?: 1, index + 1, s)!!, + imageUrl = "$thumbServer/$path", ) }, false, @@ -204,15 +197,24 @@ class NHentai(delegate: HttpSource, val context: Context) : ) } - private fun thumbnailUrlFromType( - mediaId: String, - mediaServer: Int, - page: Int, - t: String, - ) = NHentaiSearchMetadata.typeToExtension(t)?.let { - "https://t$mediaServer.nhentai.net/galleries/$mediaId/${page}t.$it" + var nhConfig: JsonConfig? = null + suspend fun getNhConfig() { + try { + val response = + withIOContext { client.newCall(GET("https://nhentai.net/api/v2/config", headers)).awaitSuccess() } + val body = response.body.string() + nhConfig = jsonParser.decodeFromString(body) + } catch (_: Exception) { + nhConfig = JsonConfig( + (1..4).map { n -> "https://i$n.nhentai.net" }, + (1..4).map { n -> "https://t$n.nhentai.net" }, + ) + } } + val thumbServer + get() = nhConfig?.thumbServers?.random() + override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response { return client.newCachelessCallWithProgress( if (cacheControl != null) { @@ -230,10 +232,6 @@ class NHentai(delegate: HttpSource, val context: Context) : private val jsonParser = Json { ignoreUnknownKeys = true } - - private val GALLERY_JSON_REGEX = Regex(".parse\\(\"(.*)\"\\);") - private val MEDIA_SERVER_REGEX = Regex("media_server\\s*:\\s*(\\d+)") - private val UNICODE_ESCAPE_REGEX = Regex("\\\\u([0-9a-fA-F]{4})") private const val TITLE_PREF = "Display manga title as:" } } diff --git a/app/src/main/java/exh/ui/metadata/adapters/NHentaiDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/NHentaiDescriptionAdapter.kt index 5b9db6bf6..c4465a57b 100644 --- a/app/src/main/java/exh/ui/metadata/adapters/NHentaiDescriptionAdapter.kt +++ b/app/src/main/java/exh/ui/metadata/adapters/NHentaiDescriptionAdapter.kt @@ -60,8 +60,8 @@ fun NHentaiDescription(state: State.Success, openMetadataViewer: () -> Unit) { binding.pages.text = context.pluralStringResource( SYMR.plurals.num_pages, - meta.pageImageTypes.size, - meta.pageImageTypes.size, + meta.pageImagePreviewUrls.size, + meta.pageImagePreviewUrls.size, ) binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24) diff --git a/source-api/src/commonMain/kotlin/exh/metadata/metadata/NHentaiSearchMetadata.kt b/source-api/src/commonMain/kotlin/exh/metadata/metadata/NHentaiSearchMetadata.kt index 206700a52..70553d3b3 100644 --- a/source-api/src/commonMain/kotlin/exh/metadata/metadata/NHentaiSearchMetadata.kt +++ b/source-api/src/commonMain/kotlin/exh/metadata/metadata/NHentaiSearchMetadata.kt @@ -28,15 +28,13 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() { var favoritesCount: Long? = null var mediaId: String? = null - var mediaServer: Int? = null var japaneseTitle by titleDelegate(TITLE_TYPE_JAPANESE) var englishTitle by titleDelegate(TITLE_TYPE_ENGLISH) var shortTitle by titleDelegate(TITLE_TYPE_SHORT) - var coverImageType: String? = null - var pageImageTypes: List = emptyList() - var thumbnailImageType: String? = null + var coverImageUrl: String? = null + var pageImagePreviewUrls: List = emptyList() var scanlator: String? = null @@ -45,14 +43,6 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() { override fun createMangaInfo(manga: SManga): SManga { val key = nhId?.let { nhIdToPath(it) } - val cover = if (mediaId != null) { - typeToExtension(coverImageType)?.let { - "https://t${mediaServer ?: 1}.nhentai.net/galleries/$mediaId/cover.$it" - } - } else { - null - } - val title = when (preferredTitle) { TITLE_TYPE_SHORT -> shortTitle ?: englishTitle ?: japaneseTitle ?: manga.title 0, TITLE_TYPE_ENGLISH -> englishTitle ?: japaneseTitle ?: shortTitle ?: manga.title @@ -85,7 +75,7 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() { return manga.copy( url = key ?: manga.url, - thumbnail_url = cover ?: manga.thumbnail_url, + thumbnail_url = coverImageUrl ?: manga.thumbnail_url, title = title, artist = group ?: manga.artist, author = artist ?: manga.artist, @@ -113,9 +103,8 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() { getItem(japaneseTitle) { stringResource(SYMR.strings.japanese_title) }, getItem(englishTitle) { stringResource(SYMR.strings.english_title) }, getItem(shortTitle) { stringResource(SYMR.strings.short_title) }, - getItem(coverImageType) { stringResource(SYMR.strings.cover_image_file_type) }, - getItem(pageImageTypes.size) { stringResource(SYMR.strings.page_count) }, - getItem(thumbnailImageType) { stringResource(SYMR.strings.thumbnail_image_file_type) }, + getItem(coverImageUrl) { stringResource(SYMR.strings.thumbnail_url) }, + getItem(pageImagePreviewUrls.size) { stringResource(SYMR.strings.page_count) }, getItem(scanlator) { stringResource(MR.strings.scanlator) }, ) } @@ -134,15 +123,6 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() { private const val NHENTAI_GROUP_NAMESPACE = "group" const val NHENTAI_CATEGORIES_NAMESPACE = "category" - fun typeToExtension(t: String?) = - when (t) { - "w" -> "webp" - "p" -> "png" - "j" -> "jpg" - "g" -> "gif" - else -> null - } - fun nhUrlToId(url: String) = url.split("/").last { it.isNotBlank() }.toLong()