From be9c14bcaeca1e031a145aeab0661fdee6cdde36 Mon Sep 17 00:00:00 2001 From: FourTOne5 <107297513+FourTOne5@users.noreply.github.com> Date: Thu, 19 Dec 2024 17:48:32 +0600 Subject: [PATCH] WebNovel: Fix cover url and refactor (#6672) --- src/en/webnovel/build.gradle | 2 +- .../extension/en/webnovel/WebNovel.kt | 83 ++------ .../extension/en/webnovel/WebNovelDto.kt | 201 +++++++++++++----- 3 files changed, 165 insertions(+), 121 deletions(-) diff --git a/src/en/webnovel/build.gradle b/src/en/webnovel/build.gradle index 138242816..62ed70c7f 100644 --- a/src/en/webnovel/build.gradle +++ b/src/en/webnovel/build.gradle @@ -1,7 +1,7 @@ ext { extName = 'WebNovel' extClass = '.WebNovel' - extVersionCode = 12 + extVersionCode = 13 } apply from: "$rootDir/common.gradle" diff --git a/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebNovel.kt b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebNovel.kt index 40e772cb4..b0cd17ae3 100644 --- a/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebNovel.kt +++ b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebNovel.kt @@ -22,7 +22,6 @@ import uy.kohesive.injekt.injectLazy import java.io.IOException import java.util.Calendar import java.util.Date -import java.util.Locale class WebNovel : HttpSource() { @@ -34,7 +33,7 @@ class WebNovel : HttpSource() { private val baseApiUrl = "$baseUrl$BASE_API_ENDPOINT" - private val baseCoverURl = baseUrl.replace("www", "img") + private val baseCoverURl = baseUrl.replace("www", "book-pic") private val baseCdnUrl = baseUrl.replace("www", "comic-image") @@ -89,60 +88,22 @@ class WebNovel : HttpSource() { } override fun searchMangaParse(response: Response): MangasPage { - val browseResponseDto = if (response.request.url.toString().contains(QUERY_SEARCH_PATH)) { - response.parseAsForWebNovel().browseResponse + return if (response.request.url.toString().contains(QUERY_SEARCH_PATH)) { + response.parseAsForWebNovel().toMangasPage(::getCoverUrl) } else { - response.parseAsForWebNovel() + response.parseAsForWebNovel().toMangasPage(::getCoverUrl) } - - val manga = browseResponseDto.items.map { - SManga.create().apply { - title = it.name - url = it.id - thumbnail_url = getCoverUrl(it.id) - } - } - - return MangasPage(manga, browseResponseDto.isLast == 0) } // Manga details override fun getMangaUrl(manga: SManga): String = "$baseUrl/comic/${manga.getId}" - override fun fetchMangaDetails(manga: SManga): Observable { - return client.newCall(mangaDetailsRequest(manga)) - .asObservableSuccess() - .map { response -> - mangaDetailsParse(response) - } - } - override fun mangaDetailsRequest(manga: SManga): Request { return GET("$baseApiUrl/comic/getComicDetailPage?comicId=${manga.getId}", headers) } override fun mangaDetailsParse(response: Response): SManga { - val comic = response.parseAsForWebNovel().info - return SManga.create().apply { - title = comic.name - url = comic.id - thumbnail_url = getCoverUrl(comic.id) - author = comic.authorName - description = buildString { - append(comic.description) - if (comic.actionStatus == ComicDetailInfoDto.ONGOING && comic.updateCycle.isNotBlank()) { - append("\n\nInformation:") - append("\n• ${comic.updateCycle.replaceFirstChar { it.uppercase(Locale.ENGLISH) }}") - } - } - genre = comic.categoryName - status = when (comic.actionStatus) { - ComicDetailInfoDto.ONGOING -> SManga.ONGOING - ComicDetailInfoDto.COMPLETED -> SManga.COMPLETED - ComicDetailInfoDto.ON_HIATUS -> SManga.ON_HIATUS - else -> SManga.UNKNOWN - } - } + return response.parseAsForWebNovel().toSManga(::getCoverUrl) } // chapters @@ -151,9 +112,9 @@ class WebNovel : HttpSource() { } override fun chapterListParse(response: Response): List { - val chapterList = response.parseAsForWebNovel() - val comic = chapterList.comicInfo - val chapters = chapterList.comicChapters.reversed().asSequence() + val chapterList = response.parseAsForWebNovel() + val comic = chapterList.comic + val chapters = chapterList.chapters.reversed().asSequence() val accurateUpdateTimes = runCatching { client.newCall(GET("$WEBNOVEL_UPLOAD_TIME/${comic.id}.json")) @@ -164,30 +125,19 @@ class WebNovel : HttpSource() { val updateTimes = chapters.map { accurateUpdateTimes[it.id] ?: it.publishTime.toDate() } - // You can pay to get some chapter earlier than others. This privilege is divided into some tiers - // We check if user's tier same or more than chapter's. - val filteredChapters = chapters.filter { it.userLevel >= it.chapterLevel } + val filteredChapters = chapters.filter { it.isVisible } // When new privileged chapter is released oldest privileged chapter becomes normal one (in most cases) // but since those normal chapter retain the original upload time we improvise. (This isn't optimal but meh) return filteredChapters.zip(updateTimes) { chapter, updateTime -> - val namePrefix = when { - chapter.isPremium && !chapter.isAccessibleByUser -> "\uD83D\uDD12 " - else -> "" - } SChapter.create().apply { - name = namePrefix + chapter.name + name = if (chapter.isLocked) "\uD83D\uDD12 ${chapter.name}" else chapter.name url = "${comic.id}:${chapter.id}" date_upload = updateTime } }.toList() } - private val ComicChapterDto.isPremium get() = isVip != 0 || price != 0 - - // This can mean the chapter is free or user has paid to unlock it (check with [isPremium] for this case) - private val ComicChapterDto.isAccessibleByUser get() = isAuth == 1 - private fun String.toDate(): Long { if (contains("now", ignoreCase = true)) return Date().time @@ -224,7 +174,7 @@ class WebNovel : HttpSource() { } private fun pageListRequest(comicId: String, chapterId: String): Request { - // Given a high [width] parameter it gives the highest resolution image available + // Given a high [width] value WebNovel returns the highest resolution image publicly available return GET("$baseApiUrl/comic/getContent?comicId=$comicId&chapterId=$chapterId&width=9999") } @@ -235,14 +185,13 @@ class WebNovel : HttpSource() { // LinkedHashMap with a capacity of 25. When exceeding the capacity the oldest entry is removed. private val chapterPageCache = object : LinkedHashMap>() { - override fun removeEldestEntry(eldest: MutableMap.MutableEntry>?): Boolean { return size > 25 } } override fun pageListParse(response: Response): List { - val chapterContent = response.parseAsForWebNovel().chapterContent + val chapterContent = response.parseAsForWebNovel().data return chapterContent.pages.map { ChapterPage(it.id, it.url) } .also { chapterPageCache[chapterContent.id.toString()] = it } .mapIndexed { i, chapterPage -> Page(i, imageUrl = chapterPage.url) } @@ -271,8 +220,8 @@ class WebNovel : HttpSource() { return comicId to chapterId } - private fun getCoverUrl(comicId: String): String { - return "$baseCoverURl/bookcover/$comicId/0/600.jpg" + private fun getCoverUrl(comicId: String, coverUpdatedAt: Long): String { + return "$baseCoverURl/bookcover/$comicId?imageId=$coverUpdatedAt&imageMogr2/thumbnail/1024x" } private fun csrfTokenInterceptor(chain: Interceptor.Chain): Response { @@ -333,9 +282,9 @@ class WebNovel : HttpSource() { } private inline fun Response.parseAsForWebNovel(): T = use { - val parsed = parseAs>() + val parsed = parseAs>() if (parsed.code != 0) error("Error ${parsed.code}: ${parsed.msg}") - requireNotNull(parsed.data) { "Response data is null" } + requireNotNull(parsed.data) { "Received response data was null" } } private inline fun List<*>.firstInstanceOrNull() = firstOrNull { it is T } as? T diff --git a/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebNovelDto.kt b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebNovelDto.kt index 1e4b1c347..2f001f61c 100644 --- a/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebNovelDto.kt +++ b/src/en/webnovel/src/eu/kanade/tachiyomi/extension/en/webnovel/WebNovelDto.kt @@ -1,86 +1,181 @@ package eu.kanade.tachiyomi.extension.en.webnovel +import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames +import java.util.Locale @Serializable -data class ResponseDto( +class ResponseWrapper( val code: Int, val data: T?, val msg: String, ) @Serializable -data class QuerySearchResponseDto( - @SerialName("comicInfo") val browseResponse: BrowseResponseDto, -) - -@Serializable -data class BrowseResponseDto( - val isLast: Int, - @JsonNames("comicItems") val items: List, -) - -@Serializable -data class ComicInfoDto( - @JsonNames("bookId", "comicId") val id: String, - @JsonNames("bookName", "comicName") val name: String, -) - -@Serializable -data class ComicDetailInfoResponseDto( - @SerialName("comicInfo") val info: ComicDetailInfoDto, -) - -@Serializable -data class ComicDetailInfoDto( - @SerialName("comicId") val id: String, - @SerialName("comicName") val name: String, - val actionStatus: Int, - val authorName: String, - val categoryName: String, - val description: String, - val updateCycle: String, +class QuerySearchResponse( + @SerialName("comicInfo") private val response: BrowseResponse, ) { - companion object { - const val ONGOING = 1 - const val COMPLETED = 2 - const val ON_HIATUS = 3 + fun toMangasPage(coverUrl: (id: String, coverUpdatedAt: Long) -> String): MangasPage { + return response.toMangasPage(coverUrl) + } +} + +typealias FilterSearchResponse = BrowseResponse + +@Serializable +class BrowseResponse( + private val isLast: Int, + @JsonNames("comicItems") private val items: List, +) { + fun toMangasPage(coverUrl: (id: String, coverUpdatedAt: Long) -> String): MangasPage { + return MangasPage( + mangas = items.map { it.toSManga(coverUrl) }, + hasNextPage = isLast == 0, + ) } } @Serializable -data class ComicChapterListDto( - val comicInfo: ComicInfoDto, - val comicChapters: List, -) +class QuerySearchItem( + @SerialName("comicId") private val id: String, + @SerialName("bookName") private val title: String, + @SerialName("categoryName") private val genre: String, + @SerialName("CV") private val coverUpdatedAt: Long, +) : ComicItem { + override fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga { + return SManga.create().also { + it.url = id + it.title = title + it.genre = genre + it.thumbnail_url = coverUrl(id, coverUpdatedAt) + } + } +} @Serializable -data class ComicChapterDto( +class FilterSearchItem( + @SerialName("bookId") private val id: String, + @SerialName("bookName") private val title: String, + @SerialName("authorName") private val author: String, + private val description: String, + @SerialName("categoryName") private val genre: String, + @SerialName("coverUpdateTime") private val coverUpdatedAt: Long, +) : ComicItem { + override fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga { + return SManga.create().also { + it.url = id + it.title = title + it.author = author + it.description = description + it.genre = genre + it.thumbnail_url = coverUrl(id, coverUpdatedAt) + } + } +} + +@Serializable +class ComicDetailInfoResponse( + @SerialName("comicInfo") private val comic: ComicDetailInfo, +) : ComicItem { + override fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga { + return comic.toSManga(coverUrl) + } +} + +@Serializable +data class ComicDetailInfo( + @SerialName("comicId") private val id: String, + @SerialName("comicName") private val title: String, + @SerialName("authorName") private val author: String, + private val description: String, + private val updateCycle: String, + @SerialName("categoryName") private val genre: String, + @SerialName("actionStatus") private val status: Int, + @SerialName("CV") private val coverUpdatedAt: Long, +) : ComicItem { + override fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga { + return SManga.create().also { + it.url = id + it.title = title + it.author = author + it.description = buildString { + append(description) + if (status == ONGOING && updateCycle.isNotBlank()) { + append("\n\nInformation:") + append("\n• ${updateCycle.replaceFirstChar { c -> c.uppercase(Locale.ENGLISH) }}") + } + } + it.genre = genre + it.status = when (status) { + ONGOING -> SManga.ONGOING + COMPLETED -> SManga.COMPLETED + ON_HIATUS -> SManga.ON_HIATUS + else -> SManga.UNKNOWN + } + it.thumbnail_url = coverUrl(id, coverUpdatedAt) + } + } + + companion object { + private const val ONGOING = 1 + private const val COMPLETED = 2 + private const val ON_HIATUS = 3 + } +} + +@Serializable +class ComicChapterListResponse( + @SerialName("comicInfo") val comic: Comic, + @SerialName("comicChapters") val chapters: List, +) { + @Serializable + class Comic(@SerialName("comicId") val id: String) +} + +@Serializable +class ComicChapter( @SerialName("chapterId") val id: String, @SerialName("chapterName") val name: String, val publishTime: String, - val price: Int, - val isVip: Int, - val isAuth: Int, - val chapterLevel: Int, - val userLevel: Int, + + private val chapterLevel: Int, + private val userLevel: Int, + private val price: Int, + private val isVip: Int, + private val isAuth: Int, +) { + val isLocked = isPremium && !isAccessibleByUser + + // You can pay to get some chapter earlier than others. This privilege is divided into some tiers + // We check if user's tier same or more than chapter's. + val isVisible = userLevel >= chapterLevel + + private val isPremium: Boolean get() = isVip != 0 || price != 0 + + // This can mean the chapter is free or user has paid to unlock it (check with [isPremium] for this case) + private val isAccessibleByUser: Boolean get() = isAuth == 1 +} + +@Serializable +class ChapterContentResponse( + @SerialName("chapterInfo") val data: ChapterContent, ) @Serializable -data class ChapterContentResponseDto( - @SerialName("chapterInfo") val chapterContent: ChapterContentDto, -) - -@Serializable -data class ChapterContentDto( +class ChapterContent( @SerialName("chapterId") val id: Long, - @SerialName("chapterPage") val pages: List, + @SerialName("chapterPage") val pages: List, ) @Serializable -data class ChapterPageDto( +class ChapterPage( @SerialName("pageId") val id: String, val url: String, ) + +interface ComicItem { + fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga +}