DMZJ: tweak page list parsing and remove special lists (#16427)

* remove special lists

* use kotlin class for tags

* adjust page list

* bump version

* tweak preference summary
This commit is contained in:
stevenyomi 2023-05-14 10:39:03 +08:00 committed by GitHub
parent ce08808666
commit 24147b6556
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 40 additions and 135 deletions

View File

@ -116,7 +116,9 @@ class ChapterImagesDto(
@ProtoNumber(3) val name: String, @ProtoNumber(3) val name: String,
@ProtoNumber(4) val order: Int, @ProtoNumber(4) val order: Int,
@ProtoNumber(5) val direction: Int, @ProtoNumber(5) val direction: Int,
// initial letter is sometimes different from that in original URLs, see manga ID 56649
@ProtoNumber(6) val lowResImages: List<String>, @ProtoNumber(6) val lowResImages: List<String>,
// page count of low-res images
@ProtoNumber(7) val pageCount: Int?, @ProtoNumber(7) val pageCount: Int?,
@ProtoNumber(8) val images: List<String>, @ProtoNumber(8) val images: List<String>,
@ProtoNumber(9) val commentCount: Int, @ProtoNumber(9) val commentCount: Int,

View File

@ -6,7 +6,7 @@ ext {
extName = 'DMZJ' extName = 'DMZJ'
pkgNameSuffix = 'zh.dmzj' pkgNameSuffix = 'zh.dmzj'
extClass = '.Dmzj' extClass = '.Dmzj'
extVersionCode = 38 extVersionCode = 39
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -97,11 +97,9 @@ object ApiV3 {
@Serializable @Serializable
class ChapterImagesDto( class ChapterImagesDto(
private val id: Int,
private val comic_id: Int,
private val page_url: List<String>, private val page_url: List<String>,
) { ) {
fun toPageList() = parsePageList(comic_id, id, page_url, emptyList()) fun toPageList() = parsePageList(page_url)
} }
@Serializable @Serializable

View File

@ -20,20 +20,17 @@ object ApiV4 {
fun mangaInfoUrl(id: String) = "$v4apiUrl/comic/detail/$id?uid=2665531" fun mangaInfoUrl(id: String) = "$v4apiUrl/comic/detail/$id?uid=2665531"
fun parseMangaInfo(response: Response): ParseResult { fun parseMangaInfo(response: Response): MangaDto? {
val result: ResponseDto<MangaDto> = response.decrypt() val result: ResponseDto<MangaDto> = response.decrypt()
return when (val manga = result.data) { return result.data
null -> ParseResult.Error(result.message)
else -> ParseResult.Ok(manga)
}
} }
// path = "mangaId/chapterId" // path = "mangaId/chapterId"
fun chapterImagesUrl(path: String) = "$v4apiUrl/comic/chapter/$path" fun chapterImagesUrl(path: String) = "$v4apiUrl/comic/chapter/$path"
fun parseChapterImages(response: Response): ArrayList<Page> { fun parseChapterImages(response: Response, isLowRes: Boolean): ArrayList<Page> {
val result: ResponseDto<ChapterImagesDto> = response.decrypt() val result: ResponseDto<ChapterImagesDto> = response.decrypt()
return result.data!!.toPageList() return result.data!!.toPageList(isLowRes)
} }
fun rankingUrl(page: Int, filters: RankingGroup) = fun rankingUrl(page: Int, filters: RankingGroup) =
@ -128,12 +125,18 @@ object ApiV4 {
@Serializable @Serializable
class ChapterImagesDto( class ChapterImagesDto(
@ProtoNumber(1) private val id: Int,
@ProtoNumber(2) private val mangaId: Int,
@ProtoNumber(6) private val lowResImages: List<String>, @ProtoNumber(6) private val lowResImages: List<String>,
@ProtoNumber(8) private val images: List<String>, @ProtoNumber(8) private val images: List<String>,
) { ) {
fun toPageList() = parsePageList(mangaId, id, images, lowResImages) fun toPageList(isLowRes: Boolean) =
// page count can be messy, see manga ID 55847 chapters 107-109
if (images.size == lowResImages.size) {
parsePageList(images, lowResImages)
} else if (isLowRes) {
parsePageList(lowResImages, lowResImages)
} else {
parsePageList(images)
}
} }
// same as ApiV3.MangaDto // same as ApiV3.MangaDto
@ -167,10 +170,5 @@ object ApiV4 {
@ProtoNumber(3) val data: T?, @ProtoNumber(3) val data: T?,
) )
sealed interface ParseResult {
class Ok(val manga: MangaDto) : ParseResult
class Error(val message: String?) : ParseResult
}
private val cipher by lazy { RSA.getPrivateKey("MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAK8nNR1lTnIfIes6oRWJNj3mB6OssDGx0uGMpgpbVCpf6+VwnuI2stmhZNoQcM417Iz7WqlPzbUmu9R4dEKmLGEEqOhOdVaeh9Xk2IPPjqIu5TbkLZRxkY3dJM1htbz57d/roesJLkZXqssfG5EJauNc+RcABTfLb4IiFjSMlTsnAgMBAAECgYEAiz/pi2hKOJKlvcTL4jpHJGjn8+lL3wZX+LeAHkXDoTjHa47g0knYYQteCbv+YwMeAGupBWiLy5RyyhXFoGNKbbnvftMYK56hH+iqxjtDLnjSDKWnhcB7089sNKaEM9Ilil6uxWMrMMBH9v2PLdYsqMBHqPutKu/SigeGPeiB7VECQQDizVlNv67go99QAIv2n/ga4e0wLizVuaNBXE88AdOnaZ0LOTeniVEqvPtgUk63zbjl0P/pzQzyjitwe6HoCAIpAkEAxbOtnCm1uKEp5HsNaXEJTwE7WQf7PrLD4+BpGtNKkgja6f6F4ld4QZ2TQ6qvsCizSGJrjOpNdjVGJ7bgYMcczwJBALvJWPLmDi7ToFfGTB0EsNHZVKE66kZ/8Stx+ezueke4S556XplqOflQBjbnj2PigwBN/0afT+QZUOBOjWzoDJkCQClzo+oDQMvGVs9GEajS/32mJ3hiWQZrWvEzgzYRqSf3XVcEe7PaXSd8z3y3lACeeACsShqQoc8wGlaHXIJOHTcCQQCZw5127ZGs8ZDTSrogrH73Kw/HvX55wGAeirKYcv28eauveCG7iyFR0PFB/P/EDZnyb+ifvyEFlucPUI0+Y87F") } private val cipher by lazy { RSA.getPrivateKey("MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBAK8nNR1lTnIfIes6oRWJNj3mB6OssDGx0uGMpgpbVCpf6+VwnuI2stmhZNoQcM417Iz7WqlPzbUmu9R4dEKmLGEEqOhOdVaeh9Xk2IPPjqIu5TbkLZRxkY3dJM1htbz57d/roesJLkZXqssfG5EJauNc+RcABTfLb4IiFjSMlTsnAgMBAAECgYEAiz/pi2hKOJKlvcTL4jpHJGjn8+lL3wZX+LeAHkXDoTjHa47g0knYYQteCbv+YwMeAGupBWiLy5RyyhXFoGNKbbnvftMYK56hH+iqxjtDLnjSDKWnhcB7089sNKaEM9Ilil6uxWMrMMBH9v2PLdYsqMBHqPutKu/SigeGPeiB7VECQQDizVlNv67go99QAIv2n/ga4e0wLizVuaNBXE88AdOnaZ0LOTeniVEqvPtgUk63zbjl0P/pzQzyjitwe6HoCAIpAkEAxbOtnCm1uKEp5HsNaXEJTwE7WQf7PrLD4+BpGtNKkgja6f6F4ld4QZ2TQ6qvsCizSGJrjOpNdjVGJ7bgYMcczwJBALvJWPLmDi7ToFfGTB0EsNHZVKE66kZ/8Stx+ezueke4S556XplqOflQBjbnj2PigwBN/0afT+QZUOBOjWzoDJkCQClzo+oDQMvGVs9GEajS/32mJ3hiWQZrWvEzgzYRqSf3XVcEe7PaXSd8z3y3lACeeACsShqQoc8wGlaHXIJOHTcCQQCZw5127ZGs8ZDTSrogrH73Kw/HvX55wGAeirKYcv28eauveCG7iyFR0PFB/P/EDZnyb+ifvyEFlucPUI0+Y87F") }
} }

View File

@ -24,7 +24,7 @@ object CommentsInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
val response = chain.proceed(request) val response = chain.proceed(request)
if (request.tag(Tag::class.java) == null) return response if (request.tag(Tag::class) == null) return response
val comments = ApiV3.parseChapterComments(response) val comments = ApiV3.parseChapterComments(response)
.take(MAX_HEIGHT / (UNIT * 2)) .take(MAX_HEIGHT / (UNIT * 2))

View File

@ -4,11 +4,9 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Response import okhttp3.Response
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.net.URLDecoder import java.net.URLDecoder
import kotlin.math.max
const val PREFIX_ID_SEARCH = "id:" const val PREFIX_ID_SEARCH = "id:"
@ -42,34 +40,18 @@ fun String.formatChapterName(): String {
return "$number$type" return "$number$type"
} }
private const val imageSmallUrl = "https://imgsmall.idmzj.com"
fun parsePageList( fun parsePageList(
mangaId: Int,
chapterId: Int,
images: List<String>, images: List<String>,
lowResImages: List<String>, lowResImages: List<String> = List(images.size) { "" },
): ArrayList<Page> { ): ArrayList<Page> {
// page count can be messy, see manga ID 55847 chapters 107-109 val pageCount = images.size
val pageCount = max(images.size, lowResImages.size)
val list = ArrayList<Page>(pageCount + 1) // for comments page val list = ArrayList<Page>(pageCount + 1) // for comments page
for (i in 0 until pageCount) { for (i in 0 until pageCount) {
val imageUrl = images.getOrNull(i)?.fixFilename()?.toHttps() list.add(Page(i, lowResImages[i], images[i]))
val lowResUrl = lowResImages.getOrElse(i) {
// this is sometimes different in low-res URLs and might fail, see manga ID 56649
val initial = imageUrl!!.decodePath().toHttpUrl().pathSegments[0]
"$imageSmallUrl/$initial/$mangaId/$chapterId/$i.jpg"
}.toHttps()
list.add(Page(i, url = lowResUrl, imageUrl = imageUrl ?: lowResUrl))
} }
return list return list
} }
fun String.toHttps() = "https:" + substringAfter(':')
// see https://github.com/tachiyomiorg/tachiyomi-extensions/issues/3457
fun String.fixFilename() = if (endsWith(".jp")) this + 'g' else this
fun String.decodePath(): String = URLDecoder.decode(this, "UTF-8") fun String.decodePath(): String = URLDecoder.decode(this, "UTF-8")
const val COMMENTS_FLAG = "COMMENTS" const val COMMENTS_FLAG = "COMMENTS"

View File

@ -2,7 +2,6 @@ package eu.kanade.tachiyomi.extension.zh.dmzj
import android.app.Application import android.app.Application
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Log
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.interceptor.rateLimit import eu.kanade.tachiyomi.network.interceptor.rateLimit
@ -32,7 +31,6 @@ class Dmzj : ConfigurableSource, HttpSource() {
private val preferences: SharedPreferences = private val preferences: SharedPreferences =
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
.migrate()
override val client: OkHttpClient = network.client.newBuilder() override val client: OkHttpClient = network.client.newBuilder()
.addInterceptor(ImageUrlInterceptor) .addInterceptor(ImageUrlInterceptor)
@ -57,18 +55,7 @@ class Dmzj : ConfigurableSource, HttpSource() {
private fun fetchMangaInfoV4(id: String): ApiV4.MangaDto? { private fun fetchMangaInfoV4(id: String): ApiV4.MangaDto? {
val response = retryClient.newCall(GET(ApiV4.mangaInfoUrl(id), headers)).execute() val response = retryClient.newCall(GET(ApiV4.mangaInfoUrl(id), headers)).execute()
return when (val result = ApiV4.parseMangaInfo(response)) { return ApiV4.parseMangaInfo(response)
is ApiV4.ParseResult.Ok -> {
val manga = result.manga
if (manga.isLicensed) preferences.addLicensed(id)
manga
}
is ApiV4.ParseResult.Error -> {
Log.e("DMZJ", "no data for manga $id: ${result.message}")
preferences.addHidden(id)
null
}
}
} }
override fun popularMangaRequest(page: Int) = GET(ApiV3.popularMangaUrl(page), headers) override fun popularMangaRequest(page: Int) = GET(ApiV3.popularMangaUrl(page), headers)
@ -139,9 +126,7 @@ class Dmzj : ConfigurableSource, HttpSource() {
} }
private fun fetchMangaDetails(id: String): SManga { private fun fetchMangaDetails(id: String): SManga {
if (id !in preferences.hiddenList) { fetchMangaInfoV4(id)?.run { return toSManga() }
fetchMangaInfoV4(id)?.run { return toSManga() }
}
val response = client.newCall(GET(ApiV3.mangaInfoUrlV1(id), headers)).execute() val response = client.newCall(GET(ApiV3.mangaInfoUrlV1(id), headers)).execute()
return ApiV3.parseMangaDetailsV1(response) return ApiV3.parseMangaDetailsV1(response)
} }
@ -159,16 +144,14 @@ class Dmzj : ConfigurableSource, HttpSource() {
throw UnsupportedOperationException() throw UnsupportedOperationException()
} }
override fun chapterListRequest(manga: SManga): Request = throw UnsupportedOperationException("Not used.") override fun chapterListRequest(manga: SManga): Request = throw UnsupportedOperationException()
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.fromCallable { return Observable.fromCallable {
val id = manga.url.extractMangaId() val id = manga.url.extractMangaId()
if (id !in preferences.licensedList && id !in preferences.hiddenList) { val result = fetchMangaInfoV4(id)
val result = fetchMangaInfoV4(id) if (result != null && !result.isLicensed) {
if (result != null && !result.isLicensed) { return@fromCallable result.parseChapterList()
return@fromCallable result.parseChapterList()
}
} }
val response = client.newCall(GET(ApiV3.mangaInfoUrlV1(id), headers)).execute() val response = client.newCall(GET(ApiV3.mangaInfoUrlV1(id), headers)).execute()
ApiV3.parseChapterListV1(response) ApiV3.parseChapterListV1(response)
@ -186,7 +169,7 @@ class Dmzj : ConfigurableSource, HttpSource() {
return Observable.fromCallable { return Observable.fromCallable {
val response = retryClient.newCall(GET(ApiV4.chapterImagesUrl(path), headers)).execute() val response = retryClient.newCall(GET(ApiV4.chapterImagesUrl(path), headers)).execute()
val result = try { val result = try {
ApiV4.parseChapterImages(response) ApiV4.parseChapterImages(response, preferences.imageQuality == LOW_RES)
} catch (_: Throwable) { } catch (_: Throwable) {
client.newCall(GET(ApiV3.chapterImagesUrlV1(path), headers)).execute() client.newCall(GET(ApiV3.chapterImagesUrlV1(path), headers)).execute()
.let(ApiV3::parseChapterImagesV1) .let(ApiV3::parseChapterImagesV1)
@ -204,31 +187,31 @@ class Dmzj : ConfigurableSource, HttpSource() {
// see https://github.com/tachiyomiorg/tachiyomi-extensions/issues/10475 // see https://github.com/tachiyomiorg/tachiyomi-extensions/issues/10475
override fun imageRequest(page: Page): Request { override fun imageRequest(page: Page): Request {
val url = page.url val url = page.url.takeIf { it.isNotEmpty() }
val imageUrl = page.imageUrl!! val imageUrl = page.imageUrl!!
if (url == COMMENTS_FLAG) { if (url == COMMENTS_FLAG) {
return GET(imageUrl, headers).newBuilder() return GET(imageUrl, headers).newBuilder()
.tag(CommentsInterceptor.Tag::class.java, CommentsInterceptor.Tag()) .tag(CommentsInterceptor.Tag::class, CommentsInterceptor.Tag())
.build() .build()
} }
val fallbackUrl = when (preferences.imageQuality) { val fallbackUrl = when (preferences.imageQuality) {
AUTO_RES -> url AUTO_RES -> url
ORIGINAL_RES -> null ORIGINAL_RES -> null
LOW_RES -> return GET(url, headers) LOW_RES -> if (url == null) null else return GET(url, headers)
else -> url else -> url
} }
return GET(imageUrl, headers).newBuilder() return GET(imageUrl, headers).newBuilder()
.tag(ImageUrlInterceptor.Tag::class.java, ImageUrlInterceptor.Tag(fallbackUrl)) .tag(ImageUrlInterceptor.Tag::class, ImageUrlInterceptor.Tag(fallbackUrl))
.build() .build()
} }
// Unused, we can get image urls directly from the chapter page // Unused, we can get image urls directly from the chapter page
override fun imageUrlParse(response: Response) = override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException("This method should not be called!") throw UnsupportedOperationException()
override fun getFilterList() = getFilterListInternal(preferences.isMultiGenreFilter) override fun getFilterList() = getFilterListInternal(preferences.isMultiGenreFilter)
override fun setupPreferenceScreen(screen: PreferenceScreen) { override fun setupPreferenceScreen(screen: PreferenceScreen) {
getPreferencesInternal(screen.context, preferences).forEach(screen::addPreference) getPreferencesInternal(screen.context).forEach(screen::addPreference)
} }
} }

View File

@ -11,7 +11,7 @@ object ImageUrlInterceptor : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
val tag = request.tag(Tag::class.java) ?: return chain.proceed(request) val tag = request.tag(Tag::class) ?: return chain.proceed(request)
try { try {
val response = chain.proceed(request) val response = chain.proceed(request)

View File

@ -3,20 +3,21 @@ package eu.kanade.tachiyomi.extension.zh.dmzj
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.ListPreference import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.SwitchPreferenceCompat import androidx.preference.SwitchPreferenceCompat
// Legacy preferences: // Legacy preferences:
// "apiRatelimitPreference" -> 1..10 default "5" // "apiRatelimitPreference" -> 1..10 default "5"
// "imgCDNRatelimitPreference" -> 1..10 default "5" // "imgCDNRatelimitPreference" -> 1..10 default "5"
// "licensedList" -> StringSet of manga ID
// "hiddenList" -> StringSet of manga ID
fun getPreferencesInternal(context: Context, preferences: SharedPreferences) = arrayOf( fun getPreferencesInternal(context: Context) = arrayOf(
ListPreference(context).apply { ListPreference(context).apply {
key = IMAGE_QUALITY_PREF key = IMAGE_QUALITY_PREF
title = "图片质量" title = "图片质量"
summary = "%s\n如果选择“只用原图”可能会有部分图片无法加载" summary = "%s\n修改后,已加载的章节需要清除章节缓存才能生效"
entries = arrayOf("优先原图", "只用原图", "只用低清") entries = arrayOf("优先原图", "只用原图 (加载出错概率更高)", "优先低清")
entryValues = arrayOf(AUTO_RES, ORIGINAL_RES, LOW_RES) entryValues = arrayOf(AUTO_RES, ORIGINAL_RES, LOW_RES)
setDefaultValue(AUTO_RES) setDefaultValue(AUTO_RES)
}, },
@ -34,18 +35,6 @@ fun getPreferencesInternal(context: Context, preferences: SharedPreferences) = a
summary = "可以更精细地筛选出同时符合多个题材的作品。" summary = "可以更精细地筛选出同时符合多个题材的作品。"
setDefaultValue(false) setDefaultValue(false)
}, },
MultiSelectListPreference(context).setupIdList(
LICENSED_LIST_PREF,
"特殊漫画 ID 列表 (1)",
preferences.licensedList.toTypedArray(),
),
MultiSelectListPreference(context).setupIdList(
HIDDEN_LIST_PREF,
"特殊漫画 ID 列表 (2)",
preferences.hiddenList.toTypedArray(),
),
) )
val SharedPreferences.imageQuality get() = getString(IMAGE_QUALITY_PREF, AUTO_RES)!! val SharedPreferences.imageQuality get() = getString(IMAGE_QUALITY_PREF, AUTO_RES)!!
@ -54,50 +43,6 @@ val SharedPreferences.showChapterComments get() = getBoolean(CHAPTER_COMMENTS_PR
val SharedPreferences.isMultiGenreFilter get() = getBoolean(MULTI_GENRE_FILTER_PREF, false) val SharedPreferences.isMultiGenreFilter get() = getBoolean(MULTI_GENRE_FILTER_PREF, false)
val SharedPreferences.licensedList: Set<String> get() = getStringSet(LICENSED_LIST_PREF, emptySet())!!
val SharedPreferences.hiddenList: Set<String> get() = getStringSet(HIDDEN_LIST_PREF, emptySet())!!
fun SharedPreferences.addLicensed(id: String) = addToSet(LICENSED_LIST_PREF, id, licensedList)
fun SharedPreferences.addHidden(id: String) = addToSet(HIDDEN_LIST_PREF, id, hiddenList)
private fun MultiSelectListPreference.setupIdList(
key: String,
title: String,
values: Array<String>,
): MultiSelectListPreference {
this.key = key
this.title = title
summary = "如果漫画网页版可以正常访问,但是应用内章节目录加载异常,可以点开列表删除记录。" +
"删除方法是【取消勾选】要删除的 ID 再点击确定,勾选的项目会保留。" +
"如果点开为空,就表示没有记录。刷新漫画页并展开简介即可查看 ID。"
entries = values
entryValues = values
setDefaultValue(emptySet<Nothing>())
return this
}
@Synchronized
private fun SharedPreferences.addToSet(key: String, id: String, oldSet: Set<String>) {
if (id in oldSet) return
val newSet = HashSet<String>((oldSet.size + 1) * 2)
newSet.addAll(oldSet)
newSet.add(id)
edit().putStringSet(key, newSet).apply()
}
fun SharedPreferences.migrate(): SharedPreferences {
val currentVersion = 1
val versionPref = "version"
val oldVersion = getInt(versionPref, 0)
if (oldVersion >= currentVersion) return this
val editor = edit()
if (oldVersion < 1) {
editor.remove(LICENSED_LIST_PREF).remove(HIDDEN_LIST_PREF)
}
editor.putInt(versionPref, currentVersion).apply()
return this
}
private const val IMAGE_QUALITY_PREF = "imageSourcePreference" private const val IMAGE_QUALITY_PREF = "imageSourcePreference"
const val AUTO_RES = "PREFER_ORIG_RES" const val AUTO_RES = "PREFER_ORIG_RES"
const val ORIGINAL_RES = "ORIG_RES_ONLY" const val ORIGINAL_RES = "ORIG_RES_ONLY"
@ -105,6 +50,3 @@ const val LOW_RES = "LOW_RES_ONLY"
private const val CHAPTER_COMMENTS_PREF = "chapterComments" private const val CHAPTER_COMMENTS_PREF = "chapterComments"
private const val MULTI_GENRE_FILTER_PREF = "multiGenreFilter" private const val MULTI_GENRE_FILTER_PREF = "multiGenreFilter"
private const val LICENSED_LIST_PREF = "licensedList"
private const val HIDDEN_LIST_PREF = "hiddenList"