WebNovel: Fix cover url and refactor (#6672)
This commit is contained in:
parent
f03fd3c5f7
commit
be9c14bcae
|
@ -1,7 +1,7 @@
|
||||||
ext {
|
ext {
|
||||||
extName = 'WebNovel'
|
extName = 'WebNovel'
|
||||||
extClass = '.WebNovel'
|
extClass = '.WebNovel'
|
||||||
extVersionCode = 12
|
extVersionCode = 13
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: "$rootDir/common.gradle"
|
apply from: "$rootDir/common.gradle"
|
||||||
|
|
|
@ -22,7 +22,6 @@ import uy.kohesive.injekt.injectLazy
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Calendar
|
import java.util.Calendar
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class WebNovel : HttpSource() {
|
class WebNovel : HttpSource() {
|
||||||
|
|
||||||
|
@ -34,7 +33,7 @@ class WebNovel : HttpSource() {
|
||||||
|
|
||||||
private val baseApiUrl = "$baseUrl$BASE_API_ENDPOINT"
|
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")
|
private val baseCdnUrl = baseUrl.replace("www", "comic-image")
|
||||||
|
|
||||||
|
@ -89,60 +88,22 @@ class WebNovel : HttpSource() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun searchMangaParse(response: Response): MangasPage {
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
val browseResponseDto = if (response.request.url.toString().contains(QUERY_SEARCH_PATH)) {
|
return if (response.request.url.toString().contains(QUERY_SEARCH_PATH)) {
|
||||||
response.parseAsForWebNovel<QuerySearchResponseDto>().browseResponse
|
response.parseAsForWebNovel<QuerySearchResponse>().toMangasPage(::getCoverUrl)
|
||||||
} else {
|
} else {
|
||||||
response.parseAsForWebNovel()
|
response.parseAsForWebNovel<FilterSearchResponse>().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
|
// Manga details
|
||||||
override fun getMangaUrl(manga: SManga): String = "$baseUrl/comic/${manga.getId}"
|
override fun getMangaUrl(manga: SManga): String = "$baseUrl/comic/${manga.getId}"
|
||||||
|
|
||||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
|
||||||
return client.newCall(mangaDetailsRequest(manga))
|
|
||||||
.asObservableSuccess()
|
|
||||||
.map { response ->
|
|
||||||
mangaDetailsParse(response)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun mangaDetailsRequest(manga: SManga): Request {
|
override fun mangaDetailsRequest(manga: SManga): Request {
|
||||||
return GET("$baseApiUrl/comic/getComicDetailPage?comicId=${manga.getId}", headers)
|
return GET("$baseApiUrl/comic/getComicDetailPage?comicId=${manga.getId}", headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun mangaDetailsParse(response: Response): SManga {
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
val comic = response.parseAsForWebNovel<ComicDetailInfoResponseDto>().info
|
return response.parseAsForWebNovel<ComicDetailInfoResponse>().toSManga(::getCoverUrl)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// chapters
|
// chapters
|
||||||
|
@ -151,9 +112,9 @@ class WebNovel : HttpSource() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun chapterListParse(response: Response): List<SChapter> {
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
val chapterList = response.parseAsForWebNovel<ComicChapterListDto>()
|
val chapterList = response.parseAsForWebNovel<ComicChapterListResponse>()
|
||||||
val comic = chapterList.comicInfo
|
val comic = chapterList.comic
|
||||||
val chapters = chapterList.comicChapters.reversed().asSequence()
|
val chapters = chapterList.chapters.reversed().asSequence()
|
||||||
|
|
||||||
val accurateUpdateTimes = runCatching {
|
val accurateUpdateTimes = runCatching {
|
||||||
client.newCall(GET("$WEBNOVEL_UPLOAD_TIME/${comic.id}.json"))
|
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() }
|
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
|
val filteredChapters = chapters.filter { it.isVisible }
|
||||||
// We check if user's tier same or more than chapter's.
|
|
||||||
val filteredChapters = chapters.filter { it.userLevel >= it.chapterLevel }
|
|
||||||
|
|
||||||
// When new privileged chapter is released oldest privileged chapter becomes normal one (in most cases)
|
// 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)
|
// but since those normal chapter retain the original upload time we improvise. (This isn't optimal but meh)
|
||||||
return filteredChapters.zip(updateTimes) { chapter, updateTime ->
|
return filteredChapters.zip(updateTimes) { chapter, updateTime ->
|
||||||
val namePrefix = when {
|
|
||||||
chapter.isPremium && !chapter.isAccessibleByUser -> "\uD83D\uDD12 "
|
|
||||||
else -> ""
|
|
||||||
}
|
|
||||||
SChapter.create().apply {
|
SChapter.create().apply {
|
||||||
name = namePrefix + chapter.name
|
name = if (chapter.isLocked) "\uD83D\uDD12 ${chapter.name}" else chapter.name
|
||||||
url = "${comic.id}:${chapter.id}"
|
url = "${comic.id}:${chapter.id}"
|
||||||
date_upload = updateTime
|
date_upload = updateTime
|
||||||
}
|
}
|
||||||
}.toList()
|
}.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 {
|
private fun String.toDate(): Long {
|
||||||
if (contains("now", ignoreCase = true)) return Date().time
|
if (contains("now", ignoreCase = true)) return Date().time
|
||||||
|
|
||||||
|
@ -224,7 +174,7 @@ class WebNovel : HttpSource() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun pageListRequest(comicId: String, chapterId: String): Request {
|
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")
|
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.
|
// LinkedHashMap with a capacity of 25. When exceeding the capacity the oldest entry is removed.
|
||||||
private val chapterPageCache = object : LinkedHashMap<String, List<ChapterPage>>() {
|
private val chapterPageCache = object : LinkedHashMap<String, List<ChapterPage>>() {
|
||||||
|
|
||||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<ChapterPage>>?): Boolean {
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, List<ChapterPage>>?): Boolean {
|
||||||
return size > 25
|
return size > 25
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun pageListParse(response: Response): List<Page> {
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
val chapterContent = response.parseAsForWebNovel<ChapterContentResponseDto>().chapterContent
|
val chapterContent = response.parseAsForWebNovel<ChapterContentResponse>().data
|
||||||
return chapterContent.pages.map { ChapterPage(it.id, it.url) }
|
return chapterContent.pages.map { ChapterPage(it.id, it.url) }
|
||||||
.also { chapterPageCache[chapterContent.id.toString()] = it }
|
.also { chapterPageCache[chapterContent.id.toString()] = it }
|
||||||
.mapIndexed { i, chapterPage -> Page(i, imageUrl = chapterPage.url) }
|
.mapIndexed { i, chapterPage -> Page(i, imageUrl = chapterPage.url) }
|
||||||
|
@ -271,8 +220,8 @@ class WebNovel : HttpSource() {
|
||||||
return comicId to chapterId
|
return comicId to chapterId
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getCoverUrl(comicId: String): String {
|
private fun getCoverUrl(comicId: String, coverUpdatedAt: Long): String {
|
||||||
return "$baseCoverURl/bookcover/$comicId/0/600.jpg"
|
return "$baseCoverURl/bookcover/$comicId?imageId=$coverUpdatedAt&imageMogr2/thumbnail/1024x"
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun csrfTokenInterceptor(chain: Interceptor.Chain): Response {
|
private fun csrfTokenInterceptor(chain: Interceptor.Chain): Response {
|
||||||
|
@ -333,9 +282,9 @@ class WebNovel : HttpSource() {
|
||||||
}
|
}
|
||||||
|
|
||||||
private inline fun <reified T> Response.parseAsForWebNovel(): T = use {
|
private inline fun <reified T> Response.parseAsForWebNovel(): T = use {
|
||||||
val parsed = parseAs<ResponseDto<T>>()
|
val parsed = parseAs<ResponseWrapper<T>>()
|
||||||
if (parsed.code != 0) error("Error ${parsed.code}: ${parsed.msg}")
|
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 <reified T> List<*>.firstInstanceOrNull() = firstOrNull { it is T } as? T
|
private inline fun <reified T> List<*>.firstInstanceOrNull() = firstOrNull { it is T } as? T
|
||||||
|
|
|
@ -1,86 +1,181 @@
|
||||||
package eu.kanade.tachiyomi.extension.en.webnovel
|
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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonNames
|
import kotlinx.serialization.json.JsonNames
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ResponseDto<T>(
|
class ResponseWrapper<T>(
|
||||||
val code: Int,
|
val code: Int,
|
||||||
val data: T?,
|
val data: T?,
|
||||||
val msg: String,
|
val msg: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class QuerySearchResponseDto(
|
class QuerySearchResponse(
|
||||||
@SerialName("comicInfo") val browseResponse: BrowseResponseDto,
|
@SerialName("comicInfo") private val response: BrowseResponse<QuerySearchItem>,
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class BrowseResponseDto(
|
|
||||||
val isLast: Int,
|
|
||||||
@JsonNames("comicItems") val items: List<ComicInfoDto>,
|
|
||||||
)
|
|
||||||
|
|
||||||
@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,
|
|
||||||
) {
|
) {
|
||||||
companion object {
|
fun toMangasPage(coverUrl: (id: String, coverUpdatedAt: Long) -> String): MangasPage {
|
||||||
const val ONGOING = 1
|
return response.toMangasPage(coverUrl)
|
||||||
const val COMPLETED = 2
|
|
||||||
const val ON_HIATUS = 3
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
typealias FilterSearchResponse = BrowseResponse<FilterSearchItem>
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ComicChapterListDto(
|
class BrowseResponse<T : ComicItem>(
|
||||||
val comicInfo: ComicInfoDto,
|
private val isLast: Int,
|
||||||
val comicChapters: List<ComicChapterDto>,
|
@JsonNames("comicItems") private val items: List<T>,
|
||||||
|
) {
|
||||||
|
fun toMangasPage(coverUrl: (id: String, coverUpdatedAt: Long) -> String): MangasPage {
|
||||||
|
return MangasPage(
|
||||||
|
mangas = items.map { it.toSManga(coverUrl) },
|
||||||
|
hasNextPage = isLast == 0,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ComicChapterDto(
|
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
|
||||||
|
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<ComicChapter>,
|
||||||
|
) {
|
||||||
|
@Serializable
|
||||||
|
class Comic(@SerialName("comicId") val id: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
class ComicChapter(
|
||||||
@SerialName("chapterId") val id: String,
|
@SerialName("chapterId") val id: String,
|
||||||
@SerialName("chapterName") val name: String,
|
@SerialName("chapterName") val name: String,
|
||||||
val publishTime: String,
|
val publishTime: String,
|
||||||
val price: Int,
|
|
||||||
val isVip: Int,
|
private val chapterLevel: Int,
|
||||||
val isAuth: Int,
|
private val userLevel: Int,
|
||||||
val chapterLevel: Int,
|
private val price: Int,
|
||||||
val userLevel: 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
|
@Serializable
|
||||||
data class ChapterContentResponseDto(
|
class ChapterContent(
|
||||||
@SerialName("chapterInfo") val chapterContent: ChapterContentDto,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class ChapterContentDto(
|
|
||||||
@SerialName("chapterId") val id: Long,
|
@SerialName("chapterId") val id: Long,
|
||||||
@SerialName("chapterPage") val pages: List<ChapterPageDto>,
|
@SerialName("chapterPage") val pages: List<ChapterPage>,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ChapterPageDto(
|
class ChapterPage(
|
||||||
@SerialName("pageId") val id: String,
|
@SerialName("pageId") val id: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface ComicItem {
|
||||||
|
fun toSManga(coverUrl: (id: String, coverUpdatedAt: Long) -> String): SManga
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue