Update HeanCMS theme (#1969)

* i hate this theme

* bump

* remove useless slug update

* lint

* Update series slug on chapter list update

This was made for sources that changed slugs constantly.

Currently no one uses it, but who knows if they enable that again

* what an unstable experience

* Remove empty lines

* Fix intl

* newline

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>

* why my build took 5 minutes

* I hate iguanas

---------

Co-authored-by: AwkwardPeak7 <48650614+AwkwardPeak7@users.noreply.github.com>
This commit is contained in:
bapeey 2024-03-19 10:21:09 -05:00 committed by GitHub
parent 16011407c5
commit 8defa56f71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 233 additions and 669 deletions

View File

@ -0,0 +1,17 @@
genre_filter_title=Genres
status_filter_title=Status
status_all=All
status_ongoing=Ongoing
status_onhiatus=On hiatus
status_dropped=Dropped
sort_by_filter_title=Sort By
sort_by_title=Title
sort_by_views=Views
sort_by_latest=Latest
sort_by_created_at=Created at
pref_show_paid_chapter_title=Display paid chapters
pref_show_paid_chapter_summary_on=Paid chapters will appear.
pref_show_paid_chapter_summary_off=Only free chapters will be displayed.
url_changed_error=The URL of the series has changed. Migrate from %s to %s to update the URL
paid_chapter_error=Paid chapter unavailable.
id_not_found_error=Failed to get the ID for slug: %s

View File

@ -0,0 +1,17 @@
genre_filter_title=Géneros
status_filter_title=Estado
status_all=Todos
status_ongoing=En curso
status_onhiatus=En hiatus
status_dropped=Abandonada
sort_by_filter_title=Ordenar por
sort_by_title=Título
sort_by_views=Número de vistas
sort_by_latest=Recientes
sort_by_created_at=Fecha de creación
pref_show_paid_chapter_title=Mostrar capítulos de pago
pref_show_paid_chapter_summary_on=Se mostrarán capítulos de pago.
pref_show_paid_chapter_summary_off=Solo se mostrarán los capítulos gratuitos.
url_changed_error= La URL de la serie ha cambiado. Migre de %s a %s para actualizar la URL
paid_chapter_error=Capítulo no disponible.
id_not_found_error=No se pudo encontrar el ID para: %s

View File

@ -0,0 +1,13 @@
genre_filter_title=Gêneros
status_filter_title=Estado
status_all=Todos
status_ongoing=Em andamento
status_onhiatus=Em hiato
status_dropped=Cancelada
sort_by_filter_title=Ordenar por
sort_by_title=Título
sort_by_views=Visualizações
sort_by_latest=Recentes
sort_by_created_at=Data de criação
url_changed_error=A URL da série mudou. Migre de %s para %s para atualizar a URL
id_not_found_error=Falha ao obter o ID do slug: %s

View File

@ -2,4 +2,8 @@ plugins {
id("lib-multisrc")
}
baseVersionCode = 20
baseVersionCode = 21
dependencies {
api(project(":lib:i18n"))
}

View File

@ -4,31 +4,25 @@ import android.app.Application
import android.content.SharedPreferences
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.lib.i18n.Intl
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
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.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
@ -39,35 +33,15 @@ abstract class HeanCms(
protected val apiUrl: String = baseUrl.replace("://", "://api."),
) : ConfigurableSource, HttpSource() {
private val preferences: SharedPreferences by lazy {
protected val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = SHOW_PAID_CHAPTERS_PREF
title = intl.prefShowPaidChapterTitle
summaryOn = intl.prefShowPaidChapterSummaryOn
summaryOff = intl.prefShowPaidChapterSummaryOff
setDefaultValue(SHOW_PAID_CHAPTERS_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
preferences.edit()
.putBoolean(SHOW_PAID_CHAPTERS_PREF, newValue as Boolean)
.commit()
}
}.also(screen::addPreference)
}
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
protected open val slugStrategy = SlugStrategy.NONE
protected open val useNewQueryEndpoint = false
private var seriesSlugMap: Map<String, HeanCmsTitle>? = null
protected open val useNewChapterEndpoint = false
/**
* Custom Json instance to make usage of `encodeDefaults`,
@ -79,9 +53,14 @@ abstract class HeanCms(
encodeDefaults = true
}
protected val intl by lazy { HeanCmsIntl(lang) }
protected val intl = Intl(
language = lang,
baseLanguage = "en",
availableLanguages = setOf("en", "pt-BR", "es"),
classLoader = this::class.java.classLoader!!,
)
protected open val coverPath: String = "cover/"
protected open val coverPath: String = ""
protected open val mangaSubDirectory: String = "series"
@ -92,29 +71,6 @@ abstract class HeanCms(
.add("Referer", "$baseUrl/")
override fun popularMangaRequest(page: Int): Request {
if (useNewQueryEndpoint) {
return newEndpointPopularMangaRequest(page)
}
val payloadObj = HeanCmsQuerySearchPayloadDto(
page = page,
order = "desc",
orderBy = "total_views",
status = "All",
type = "Comic",
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST("$apiUrl/series/querysearch", apiHeaders, payload)
}
protected fun newEndpointPopularMangaRequest(page: Int): Request {
val url = "$apiUrl/query".toHttpUrl().newBuilder()
.addQueryParameter("query_string", "")
.addQueryParameter("series_status", "All")
@ -124,66 +80,14 @@ abstract class HeanCms(
.addQueryParameter("page", page.toString())
.addQueryParameter("perPage", "12")
.addQueryParameter("tags_ids", "[]")
.addQueryParameter("adult", "true")
return GET(url.build(), headers)
}
override fun popularMangaParse(response: Response): MangasPage {
val json = response.body.string()
if (json.startsWith("{")) {
val result = json.parseAs<HeanCmsQuerySearchDto>()
val mangaList = result.data.map {
if (slugStrategy != SlugStrategy.NONE) {
preferences.slugMap = preferences.slugMap.toMutableMap()
.also { map -> map[it.slug.toPermSlugIfNeeded()] = it.slug }
}
it.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
}
fetchAllTitles()
return MangasPage(mangaList, result.meta?.hasNextPage ?: false)
}
val mangaList = json.parseAs<List<HeanCmsSeriesDto>>()
.map {
if (slugStrategy != SlugStrategy.NONE) {
preferences.slugMap = preferences.slugMap.toMutableMap()
.also { map -> map[it.slug.toPermSlugIfNeeded()] = it.slug }
}
it.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
}
fetchAllTitles()
return MangasPage(mangaList, hasNextPage = false)
}
override fun popularMangaParse(response: Response) = searchMangaParse(response)
override fun latestUpdatesRequest(page: Int): Request {
if (useNewQueryEndpoint) {
return newEndpointLatestUpdatesRequest(page)
}
val payloadObj = HeanCmsQuerySearchPayloadDto(
page = page,
order = "desc",
orderBy = "latest",
status = "All",
type = "Comic",
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST("$apiUrl/series/querysearch", apiHeaders, payload)
}
protected fun newEndpointLatestUpdatesRequest(page: Int): Request {
val url = "$apiUrl/query".toHttpUrl().newBuilder()
.addQueryParameter("query_string", "")
.addQueryParameter("series_status", "All")
@ -193,6 +97,7 @@ abstract class HeanCms(
.addQueryParameter("page", page.toString())
.addQueryParameter("perPage", "12")
.addQueryParameter("tags_ids", "[]")
.addQueryParameter("adult", "true")
return GET(url.build(), headers)
}
@ -206,12 +111,8 @@ abstract class HeanCms(
val slug = query.substringAfter(SEARCH_PREFIX)
val manga = SManga.create().apply {
url = if (slugStrategy != SlugStrategy.NONE) {
val mangaId = getIdBySlug(slug)
"/$mangaSubDirectory/${slug.toPermSlugIfNeeded()}#$mangaId"
} else {
"/$mangaSubDirectory/$slug"
}
val mangaId = getIdBySlug(slug)
url = "/$mangaSubDirectory/$slug#$mangaId"
}
return fetchMangaDetails(manga).map { MangasPage(listOf(it), false) }
@ -224,57 +125,12 @@ abstract class HeanCms(
val seriesDetail = json.parseAs<HeanCmsSeriesDto>()
preferences.slugMap = preferences.slugMap.toMutableMap()
.also { it[seriesDetail.slug.toPermSlugIfNeeded()] = seriesDetail.slug }
seriesDetail.id
}
return result.getOrNull() ?: throw Exception(intl.idNotFoundError + slug)
return result.getOrNull() ?: throw Exception(intl.format("id_not_found_error", slug))
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
if (useNewQueryEndpoint) {
return newEndpointSearchMangaRequest(page, query, filters)
}
if (query.isNotBlank()) {
val searchPayloadObj = HeanCmsSearchPayloadDto(query)
val searchPayload = json.encodeToString(searchPayloadObj)
.toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", searchPayload.contentType().toString())
.build()
return POST("$apiUrl/series/search", apiHeaders, searchPayload)
}
val sortByFilter = filters.firstInstanceOrNull<SortByFilter>()
val payloadObj = HeanCmsQuerySearchPayloadDto(
page = page,
order = if (sortByFilter?.state?.ascending == true) "asc" else "desc",
orderBy = sortByFilter?.selected ?: "total_views",
status = filters.firstInstanceOrNull<StatusFilter>()?.selected?.value ?: "Ongoing",
type = "Comic",
tagIds = filters.firstInstanceOrNull<GenreFilter>()?.state
?.filter(Genre::state)
?.map(Genre::id)
.orEmpty(),
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST("$apiUrl/series/querysearch", apiHeaders, payload)
}
protected fun newEndpointSearchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val sortByFilter = filters.firstInstanceOrNull<SortByFilter>()
val statusFilter = filters.firstInstanceOrNull<StatusFilter>()
@ -292,6 +148,7 @@ abstract class HeanCms(
.addQueryParameter("page", page.toString())
.addQueryParameter("perPage", "12")
.addQueryParameter("tags_ids", tagIds)
.addQueryParameter("adult", "true")
return GET(url.build(), headers)
}
@ -299,95 +156,34 @@ abstract class HeanCms(
override fun searchMangaParse(response: Response): MangasPage {
val json = response.body.string()
if (response.request.url.pathSegments.last() == "search") {
fetchAllTitles()
val result = json.parseAs<List<HeanCmsSearchDto>>()
val mangaList = result
.filter { it.type == "Comic" }
.map {
it.slug = it.slug.toPermSlugIfNeeded()
it.toSManga(apiUrl, coverPath, mangaSubDirectory, seriesSlugMap.orEmpty(), slugStrategy)
}
return MangasPage(mangaList, false)
val result = json.parseAs<HeanCmsQuerySearchDto>()
val mangaList = result.data.map {
it.toSManga(apiUrl, coverPath, mangaSubDirectory)
}
if (json.startsWith("{")) {
val result = json.parseAs<HeanCmsQuerySearchDto>()
val mangaList = result.data.map {
if (slugStrategy != SlugStrategy.NONE) {
preferences.slugMap = preferences.slugMap.toMutableMap()
.also { map -> map[it.slug.toPermSlugIfNeeded()] = it.slug }
}
it.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
}
fetchAllTitles()
return MangasPage(mangaList, result.meta?.hasNextPage ?: false)
}
val mangaList = json.parseAs<List<HeanCmsSeriesDto>>()
.map {
if (slugStrategy != SlugStrategy.NONE) {
preferences.slugMap = preferences.slugMap.toMutableMap()
.also { map -> map[it.slug.toPermSlugIfNeeded()] = it.slug }
}
it.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
}
fetchAllTitles()
return MangasPage(mangaList, hasNextPage = false)
return MangasPage(mangaList, result.meta?.hasNextPage() ?: false)
}
override fun getMangaUrl(manga: SManga): String {
val seriesSlug = manga.url
.substringAfterLast("/")
.substringBefore("#")
.toPermSlugIfNeeded()
val currentSlug = if (slugStrategy != SlugStrategy.NONE) {
preferences.slugMap[seriesSlug] ?: seriesSlug
} else {
seriesSlug
}
return "$baseUrl/$mangaSubDirectory/$currentSlug"
return "$baseUrl/$mangaSubDirectory/$seriesSlug"
}
override fun mangaDetailsRequest(manga: SManga): Request {
if (slugStrategy != SlugStrategy.NONE && (manga.url.contains(TIMESTAMP_REGEX))) {
throw Exception(intl.urlChangedError(name))
if (!manga.url.contains("#")) {
throw Exception(intl.format("url_changed_error", name, name))
}
if (slugStrategy == SlugStrategy.ID && !manga.url.contains("#")) {
throw Exception(intl.urlChangedError(name))
}
val seriesSlug = manga.url
.substringAfterLast("/")
.substringBefore("#")
.toPermSlugIfNeeded()
val seriesId = manga.url.substringAfterLast("#")
fetchAllTitles()
val seriesDetails = seriesSlugMap?.get(seriesSlug)
val currentSlug = seriesDetails?.slug ?: seriesSlug
val currentStatus = seriesDetails?.status ?: manga.status
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
return if (slugStrategy == SlugStrategy.ID) {
GET("$apiUrl/series/id/$seriesId", apiHeaders)
} else {
GET("$apiUrl/series/$currentSlug#$currentStatus", apiHeaders)
}
return GET("$apiUrl/series/id/$seriesId", apiHeaders)
}
override fun mangaDetailsParse(response: Response): SManga {
@ -395,14 +191,10 @@ abstract class HeanCms(
val result = runCatching { response.parseAs<HeanCmsSeriesDto>() }
val seriesResult = result.getOrNull() ?: throw Exception(intl.urlChangedError(name))
val seriesResult = result.getOrNull()
?: throw Exception(intl.format("url_changed_error", name, name))
if (slugStrategy != SlugStrategy.NONE) {
preferences.slugMap = preferences.slugMap.toMutableMap()
.also { it[seriesResult.slug.toPermSlugIfNeeded()] = seriesResult.slug }
}
val seriesDetails = seriesResult.toSManga(apiUrl, coverPath, mangaSubDirectory, slugStrategy)
val seriesDetails = seriesResult.toSManga(apiUrl, coverPath, mangaSubDirectory)
return seriesDetails.apply {
status = status.takeUnless { it == SManga.UNKNOWN }
@ -410,105 +202,97 @@ abstract class HeanCms(
}
}
override fun chapterListRequest(manga: SManga): Request = mangaDetailsRequest(manga)
override fun chapterListRequest(manga: SManga): Request {
if (useNewChapterEndpoint) {
if (!manga.url.contains("#")) {
throw Exception(intl.format("url_changed_error", name, name))
}
override fun chapterListParse(response: Response): List<SChapter> {
val result = response.parseAs<HeanCmsSeriesDto>()
val seriesId = manga.url.substringAfterLast("#")
val seriesSlug = manga.url.substringAfterLast("/").substringBefore("#")
if (slugStrategy == SlugStrategy.ID) {
preferences.slugMap = preferences.slugMap.toMutableMap()
.also { it[result.slug.toPermSlugIfNeeded()] = result.slug }
val url = "$apiUrl/chapter/query".toHttpUrl().newBuilder()
.addQueryParameter("page", "1")
.addQueryParameter("perPage", PER_PAGE_CHAPTERS.toString())
.addQueryParameter("series_id", seriesId)
.fragment(seriesSlug)
return GET(url.build(), headers)
}
val currentTimestamp = System.currentTimeMillis()
return mangaDetailsRequest(manga)
}
override fun chapterListParse(response: Response): List<SChapter> {
val showPaidChapters = preferences.showPaidChapters
if (useNewQueryEndpoint) {
return result.seasons.orEmpty()
.flatMap { it.chapters.orEmpty() }
if (useNewChapterEndpoint) {
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
val seriesId = response.request.url.queryParameter("series_id")
val seriesSlug = response.request.url.fragment!!
var result = response.parseAs<HeanCmsChapterPayloadDto>()
val currentTimestamp = System.currentTimeMillis()
val chapterList = mutableListOf<HeanCmsChapterDto>()
chapterList.addAll(result.data)
var page = 2
while (result.meta.hasNextPage()) {
val url = "$apiUrl/chapter/query".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("perPage", PER_PAGE_CHAPTERS.toString())
.addQueryParameter("series_id", seriesId)
.build()
val nextResponse = client.newCall(GET(url, apiHeaders)).execute()
result = nextResponse.parseAs<HeanCmsChapterPayloadDto>()
chapterList.addAll(result.data)
page++
}
return chapterList
.filter { it.price == 0 || showPaidChapters }
.map { it.toSChapter(result.slug, mangaSubDirectory, dateFormat, slugStrategy) }
.map { it.toSChapter(seriesSlug, mangaSubDirectory, dateFormat) }
.filter { it.date_upload <= currentTimestamp }
}
return result.chapters.orEmpty()
val result = response.parseAs<HeanCmsSeriesDto>()
val currentTimestamp = System.currentTimeMillis()
return result.seasons.orEmpty()
.flatMap { it.chapters.orEmpty() }
.filter { it.price == 0 || showPaidChapters }
.map { it.toSChapter(result.slug, mangaSubDirectory, dateFormat, slugStrategy) }
.map { it.toSChapter(result.slug, mangaSubDirectory, dateFormat) }
.filter { it.date_upload <= currentTimestamp }
.reversed()
}
override fun getChapterUrl(chapter: SChapter): String {
if (slugStrategy == SlugStrategy.NONE) return baseUrl + chapter.url
override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url.substringBeforeLast("#")
val seriesSlug = chapter.url
.substringAfter("/$mangaSubDirectory/")
.substringBefore("/")
.toPermSlugIfNeeded()
val currentSlug = preferences.slugMap[seriesSlug] ?: seriesSlug
val chapterUrl = chapter.url.replaceFirst(seriesSlug, currentSlug)
return baseUrl + chapterUrl
}
override fun pageListRequest(chapter: SChapter): Request {
if (useNewQueryEndpoint) {
if (slugStrategy != SlugStrategy.NONE) {
val seriesPermSlug = chapter.url.substringAfter("/$mangaSubDirectory/").substringBefore("/")
val seriesSlug = preferences.slugMap[seriesPermSlug] ?: seriesPermSlug
val chapterUrl = chapter.url.replaceFirst(seriesPermSlug, seriesSlug)
return GET(baseUrl + chapterUrl, headers)
}
return GET(baseUrl + chapter.url, headers)
}
val chapterId = chapter.url.substringAfterLast("#").substringBefore("-paid")
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.build()
return GET("$apiUrl/series/chapter/$chapterId", apiHeaders)
}
override fun pageListRequest(chapter: SChapter) =
GET(apiUrl + chapter.url.replace("/$mangaSubDirectory/", "/chapter/"), headers)
override fun pageListParse(response: Response): List<Page> {
if (useNewQueryEndpoint) {
val paidChapter = response.request.url.fragment?.contains("-paid")
val result = response.parseAs<HeanCmsPagePayloadDto>()
val document = response.asJsoup()
if (result.isPaywalled()) throw Exception(intl["paid_chapter_error"])
val images = document.selectFirst("div.min-h-screen > div.container > p.items-center")
if (images == null && paidChapter == true) {
throw IOException(intl.paidChapterError)
return if (useNewChapterEndpoint) {
result.chapter.chapterData?.images.orEmpty().mapIndexed { i, img ->
Page(i, imageUrl = img)
}
return images?.select("img").orEmpty().mapIndexed { i, img ->
val imageUrl = if (img.hasClass("lazy")) img.absUrl("data-src") else img.absUrl("src")
Page(i, "", imageUrl)
} else {
result.data.orEmpty().mapIndexed { i, img ->
Page(i, imageUrl = img)
}
}
val images = response.parseAs<HeanCmsReaderDto>().content?.images.orEmpty()
val paidChapter = response.request.url.fragment?.contains("-paid")
if (images.isEmpty() && paidChapter == true) {
throw IOException(intl.paidChapterError)
}
return images.filterNot { imageUrl ->
// Their image server returns HTTP 403 for hidden files that starts
// with a dot in the file name. To avoid download errors, these are removed.
imageUrl
.removeSuffix("/")
.substringAfterLast("/")
.startsWith(".")
}
.mapIndexed { i, url ->
Page(i, imageUrl = if (url.startsWith("http")) url else "$apiUrl/$url")
}
}
override fun fetchImageUrl(page: Page): Observable<String> = Observable.just(page.imageUrl!!)
@ -523,121 +307,18 @@ abstract class HeanCms(
return GET(page.imageUrl!!, imageHeaders)
}
protected open fun fetchAllTitles() {
if (!seriesSlugMap.isNullOrEmpty() || slugStrategy != SlugStrategy.FETCH_ALL) {
return
}
val result = runCatching {
var hasNextPage = true
var page = 1
val tempMap = mutableMapOf<String, HeanCmsTitle>()
while (hasNextPage) {
val response = client.newCall(allTitlesRequest(page)).execute()
val json = response.body.string()
if (json.startsWith("{")) {
val result = json.parseAs<HeanCmsQuerySearchDto>()
tempMap.putAll(parseAllTitles(result.data))
hasNextPage = result.meta?.hasNextPage ?: false
page++
} else {
val result = json.parseAs<List<HeanCmsSeriesDto>>()
tempMap.putAll(parseAllTitles(result))
hasNextPage = false
}
}
tempMap.toMap()
}
seriesSlugMap = result.getOrNull()
preferences.slugMap = preferences.slugMap.toMutableMap()
.also { it.putAll(seriesSlugMap.orEmpty().mapValues { (_, v) -> v.slug }) }
}
protected open fun allTitlesRequest(page: Int): Request {
if (useNewQueryEndpoint) {
val url = "$apiUrl/query".toHttpUrl().newBuilder()
.addQueryParameter("series_type", "Comic")
.addQueryParameter("page", page.toString())
.addQueryParameter("perPage", PER_PAGE_MANGA_TITLES.toString())
return GET(url.build(), headers)
}
val payloadObj = HeanCmsQuerySearchPayloadDto(
page = page,
order = "desc",
orderBy = "total_views",
type = "Comic",
)
val payload = json.encodeToString(payloadObj).toRequestBody(JSON_MEDIA_TYPE)
val apiHeaders = headersBuilder()
.add("Accept", ACCEPT_JSON)
.add("Content-Type", payload.contentType().toString())
.build()
return POST("$apiUrl/series/querysearch", apiHeaders, payload)
}
protected open fun parseAllTitles(result: List<HeanCmsSeriesDto>): Map<String, HeanCmsTitle> {
return result
.filter { it.type == "Comic" }
.associateBy(
keySelector = { it.slug.replace(TIMESTAMP_REGEX, "") },
valueTransform = {
HeanCmsTitle(
slug = it.slug,
thumbnailFileName = it.thumbnail,
status = it.status?.toStatus() ?: SManga.UNKNOWN,
)
},
)
}
/**
* Used to store the current slugs for sources that change it periodically and for the
* search that doesn't return the thumbnail URLs.
*/
data class HeanCmsTitle(val slug: String, val thumbnailFileName: String, val status: Int)
/**
* Used to specify the strategy to use when fetching the slug for a manga.
* This is needed because some sources change the slug periodically.
* [NONE]: Use series_slug without changes.
* [ID]: Use series_id to fetch the slug from the API.
* IMPORTANT: [ID] is only available in the new query endpoint.
* [FETCH_ALL]: Convert the slug to a permanent slug by removing the timestamp.
* At extension start, all the slugs are fetched and stored in a map.
*/
enum class SlugStrategy {
NONE, ID, FETCH_ALL
}
private fun String.toPermSlugIfNeeded(): String {
return if (slugStrategy != SlugStrategy.NONE) {
this.replace(TIMESTAMP_REGEX, "")
} else {
this
}
}
protected open fun getStatusList(): List<Status> = listOf(
Status(intl.statusAll, "All"),
Status(intl.statusOngoing, "Ongoing"),
Status(intl.statusOnHiatus, "Hiatus"),
Status(intl.statusDropped, "Dropped"),
Status(intl["status_all"], "All"),
Status(intl["status_ongoing"], "Ongoing"),
Status(intl["status_onhiatus"], "Hiatus"),
Status(intl["status_dropped"], "Dropped"),
)
protected open fun getSortProperties(): List<SortProperty> = listOf(
SortProperty(intl.sortByTitle, "title"),
SortProperty(intl.sortByViews, "total_views"),
SortProperty(intl.sortByLatest, "latest"),
SortProperty(intl.sortByCreatedAt, "created_at"),
SortProperty(intl["sort_by_title"], "title"),
SortProperty(intl["sort_by_views"], "total_views"),
SortProperty(intl["sort_by_latest"], "latest"),
SortProperty(intl["sort_by_created_at"], "created_at"),
)
protected open fun getGenreList(): List<Genre> = emptyList()
@ -646,15 +327,24 @@ abstract class HeanCms(
val genres = getGenreList()
val filters = listOfNotNull(
Filter.Header(intl.filterWarning),
StatusFilter(intl.statusFilterTitle, getStatusList()),
SortByFilter(intl.sortByFilterTitle, getSortProperties()),
GenreFilter(intl.genreFilterTitle, genres).takeIf { genres.isNotEmpty() },
StatusFilter(intl["status_filter_title"], getStatusList()),
SortByFilter(intl["sort_by_filter_title"], getSortProperties()),
GenreFilter(intl["genre_filter_title"], genres).takeIf { genres.isNotEmpty() },
)
return FilterList(filters)
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
SwitchPreferenceCompat(screen.context).apply {
key = SHOW_PAID_CHAPTERS_PREF
title = intl["pref_show_paid_chapter_title"]
summaryOn = intl["pref_show_paid_chapter_summary_on"]
summaryOff = intl["pref_show_paid_chapter_summary_off"]
setDefaultValue(SHOW_PAID_CHAPTERS_DEFAULT)
}.also(screen::addPreference)
}
protected inline fun <reified T> Response.parseAs(): T = use {
it.body.string().parseAs()
}
@ -664,18 +354,6 @@ abstract class HeanCms(
protected inline fun <reified R> List<*>.firstInstanceOrNull(): R? =
filterIsInstance<R>().firstOrNull()
protected var SharedPreferences.slugMap: MutableMap<String, String>
get() {
val jsonMap = getString(PREF_URL_MAP_SLUG, "{}")!!
val slugMap = runCatching { json.decodeFromString<Map<String, String>>(jsonMap) }
return slugMap.getOrNull()?.toMutableMap() ?: mutableMapOf()
}
set(newSlugMap) {
edit()
.putString(PREF_URL_MAP_SLUG, json.encodeToString(newSlugMap))
.apply()
}
private val SharedPreferences.showPaidChapters: Boolean
get() = getBoolean(SHOW_PAID_CHAPTERS_PREF, SHOW_PAID_CHAPTERS_DEFAULT)
@ -683,16 +361,10 @@ abstract class HeanCms(
private const val ACCEPT_IMAGE = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
private const val ACCEPT_JSON = "application/json, text/plain, */*"
private val JSON_MEDIA_TYPE = "application/json".toMediaType()
val TIMESTAMP_REGEX = """-\d{13}$""".toRegex()
private const val PER_PAGE_MANGA_TITLES = 10000
private const val PER_PAGE_CHAPTERS = 1000
const val SEARCH_PREFIX = "slug:"
private const val PREF_URL_MAP_SLUG = "pref_url_map"
private const val SHOW_PAID_CHAPTERS_PREF = "pref_show_paid_chap"
private const val SHOW_PAID_CHAPTERS_DEFAULT = false
}

View File

@ -1,6 +1,5 @@
package eu.kanade.tachiyomi.multisrc.heancms
import eu.kanade.tachiyomi.multisrc.heancms.HeanCms.SlugStrategy
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName
@ -9,59 +8,30 @@ import org.jsoup.Jsoup
import java.text.SimpleDateFormat
@Serializable
data class HeanCmsQuerySearchDto(
class HeanCmsQuerySearchDto(
val data: List<HeanCmsSeriesDto> = emptyList(),
val meta: HeanCmsQuerySearchMetaDto? = null,
)
@Serializable
data class HeanCmsQuerySearchMetaDto(
@SerialName("current_page") val currentPage: Int,
@SerialName("last_page") val lastPage: Int,
class HeanCmsQuerySearchMetaDto(
@SerialName("current_page") private val currentPage: Int,
@SerialName("last_page") private val lastPage: Int,
) {
val hasNextPage: Boolean
get() = currentPage < lastPage
fun hasNextPage() = currentPage < lastPage
}
@Serializable
data class HeanCmsSearchDto(
val description: String? = null,
@SerialName("series_slug") var slug: String,
@SerialName("series_type") val type: String,
val title: String,
val thumbnail: String? = null,
) {
fun toSManga(
apiUrl: String,
coverPath: String,
mangaSubDirectory: String,
slugMap: Map<String, HeanCms.HeanCmsTitle>,
slugStrategy: SlugStrategy,
): SManga = SManga.create().apply {
val slugOnly = slug.toPermSlugIfNeeded(slugStrategy)
val thumbnailFileName = slugMap[slugOnly]?.thumbnailFileName
title = this@HeanCmsSearchDto.title
thumbnail_url = thumbnail?.toAbsoluteThumbnailUrl(apiUrl, coverPath)
?: thumbnailFileName?.toAbsoluteThumbnailUrl(apiUrl, coverPath)
url = "/$mangaSubDirectory/$slugOnly"
}
}
@Serializable
data class HeanCmsSeriesDto(
class HeanCmsSeriesDto(
val id: Int,
@SerialName("series_slug") val slug: String,
@SerialName("series_type") val type: String = "Comic",
val author: String? = null,
val description: String? = null,
val studio: String? = null,
val status: String? = null,
val thumbnail: String,
val title: String,
val tags: List<HeanCmsTagDto>? = emptyList(),
val chapters: List<HeanCmsChapterDto>? = emptyList(),
private val author: String? = null,
private val description: String? = null,
private val studio: String? = null,
private val status: String? = null,
private val thumbnail: String,
private val title: String,
private val tags: List<HeanCmsTagDto>? = emptyList(),
val seasons: List<HeanCmsSeasonsDto>? = emptyList(),
) {
@ -69,10 +39,8 @@ data class HeanCmsSeriesDto(
apiUrl: String,
coverPath: String,
mangaSubDirectory: String,
slugStrategy: SlugStrategy,
): SManga = SManga.create().apply {
val descriptionBody = this@HeanCmsSeriesDto.description?.let(Jsoup::parseBodyFragment)
val slugOnly = slug.toPermSlugIfNeeded(slugStrategy)
title = this@HeanCmsSeriesDto.title
author = this@HeanCmsSeriesDto.author?.trim()
@ -86,89 +54,84 @@ data class HeanCmsSeriesDto(
thumbnail_url = thumbnail.ifEmpty { null }
?.toAbsoluteThumbnailUrl(apiUrl, coverPath)
status = this@HeanCmsSeriesDto.status?.toStatus() ?: SManga.UNKNOWN
url = if (slugStrategy != SlugStrategy.NONE) {
"/$mangaSubDirectory/$slugOnly#$id"
} else {
"/$mangaSubDirectory/$slug"
}
url = "/$mangaSubDirectory/$slug#$id"
}
}
@Serializable
data class HeanCmsSeasonsDto(
val index: Int,
class HeanCmsSeasonsDto(
val chapters: List<HeanCmsChapterDto>? = emptyList(),
)
@Serializable
data class HeanCmsTagDto(val name: String)
class HeanCmsTagDto(val name: String)
@Serializable
data class HeanCmsChapterDto(
val id: Int,
@SerialName("chapter_name") val name: String,
@SerialName("chapter_slug") val slug: String,
val index: String,
@SerialName("created_at") val createdAt: String,
class HeanCmsChapterPayloadDto(
val data: List<HeanCmsChapterDto>,
val meta: HeanCmsChapterMetaDto,
)
@Serializable
class HeanCmsChapterDto(
private val id: Int,
@SerialName("chapter_name") private val name: String,
@SerialName("chapter_slug") private val slug: String,
@SerialName("created_at") private val createdAt: String,
val price: Int? = null,
) {
fun toSChapter(
seriesSlug: String,
mangaSubDirectory: String,
dateFormat: SimpleDateFormat,
slugStrategy: SlugStrategy,
): SChapter = SChapter.create().apply {
val seriesSlugOnly = seriesSlug.toPermSlugIfNeeded(slugStrategy)
name = this@HeanCmsChapterDto.name.trim()
if (price != 0) {
name += " \uD83D\uDD12"
}
date_upload = runCatching { dateFormat.parse(createdAt)?.time }
.getOrNull() ?: 0L
date_upload = try {
dateFormat.parse(createdAt)?.time ?: 0L
} catch (_: Exception) {
0L
}
val paidStatus = if (price != 0 && price != null) "-paid" else ""
url = "/$mangaSubDirectory/$seriesSlugOnly/$slug#$id$paidStatus"
url = "/$mangaSubDirectory/$seriesSlug/$slug#$id"
}
}
@Serializable
data class HeanCmsReaderDto(
val content: HeanCmsReaderContentDto? = null,
class HeanCmsChapterMetaDto(
@SerialName("current_page") private val currentPage: Int,
@SerialName("last_page") private val lastPage: Int,
) {
fun hasNextPage() = currentPage < lastPage
}
@Serializable
class HeanCmsPagePayloadDto(
val chapter: HeanCmsPageDto,
private val paywall: Boolean = false,
val data: List<String>? = emptyList(),
) {
fun isPaywalled() = paywall
}
@Serializable
class HeanCmsPageDto(
@SerialName("chapter_data") val chapterData: HeanCmsPageDataDto?,
)
@Serializable
data class HeanCmsReaderContentDto(
class HeanCmsPageDataDto(
val images: List<String>? = emptyList(),
)
@Serializable
data class HeanCmsQuerySearchPayloadDto(
val order: String,
val page: Int,
@SerialName("order_by") val orderBy: String,
@SerialName("series_status") val status: String? = null,
@SerialName("series_type") val type: String,
@SerialName("tags_ids") val tagIds: List<Int> = emptyList(),
)
@Serializable
data class HeanCmsSearchPayloadDto(val term: String)
private fun String.toAbsoluteThumbnailUrl(apiUrl: String, coverPath: String): String {
return if (startsWith("https://")) this else "$apiUrl/$coverPath$this"
}
private fun String.toPermSlugIfNeeded(slugStrategy: SlugStrategy): String {
return if (slugStrategy != SlugStrategy.NONE) {
this.replace(HeanCms.TIMESTAMP_REGEX, "")
} else {
this
}
}
fun String.toStatus(): Int = when (this) {
"Ongoing" -> SManga.ONGOING
"Hiatus" -> SManga.ON_HIATUS

View File

@ -1,124 +0,0 @@
package eu.kanade.tachiyomi.multisrc.heancms
class HeanCmsIntl(lang: String) {
val availableLang: String = if (lang in AVAILABLE_LANGS) lang else ENGLISH
val genreFilterTitle: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Gêneros"
SPANISH -> "Géneros"
else -> "Genres"
}
val statusFilterTitle: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Estado"
SPANISH -> "Estado"
else -> "Status"
}
val statusAll: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Todos"
SPANISH -> "Todos"
else -> "All"
}
val statusOngoing: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Em andamento"
SPANISH -> "En curso"
else -> "Ongoing"
}
val statusOnHiatus: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Em hiato"
SPANISH -> "En hiatus"
else -> "On Hiatus"
}
val statusDropped: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Cancelada"
SPANISH -> "Abandonada"
else -> "Dropped"
}
val sortByFilterTitle: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Ordenar por"
SPANISH -> "Ordenar por"
else -> "Sort by"
}
val sortByTitle: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Título"
SPANISH -> "Titulo"
else -> "Title"
}
val sortByViews: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Visualizações"
SPANISH -> "Número de vistas"
else -> "Views"
}
val sortByLatest: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Recentes"
SPANISH -> "Recientes"
else -> "Latest"
}
val sortByCreatedAt: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Data de criação"
SPANISH -> "Fecha de creación"
else -> "Created at"
}
val filterWarning: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Os filtros serão ignorados se a busca não estiver vazia."
SPANISH -> "Los filtros serán ignorados si la búsqueda no está vacía."
else -> "Filters will be ignored if the search is not empty."
}
val prefShowPaidChapterTitle: String = when (availableLang) {
SPANISH -> "Mostrar capítulos de pago"
else -> "Display paid chapters"
}
val prefShowPaidChapterSummaryOn: String = when (availableLang) {
SPANISH -> "Se mostrarán capítulos de pago. Deberá iniciar sesión"
else -> "Paid chapters will appear. A login might be needed!"
}
val prefShowPaidChapterSummaryOff: String = when (availableLang) {
SPANISH -> "Solo se mostrarán los capítulos gratuitos"
else -> "Only free chapters will be displayed."
}
val paidChapterError: String = when (availableLang) {
SPANISH -> "Capítulo no disponible. Debe iniciar sesión en Webview y tener el capítulo comprado."
else -> "Paid chapter unavailable.\nA login/purchase might be needed (using webview)."
}
fun urlChangedError(sourceName: String): String = when (availableLang) {
BRAZILIAN_PORTUGUESE ->
"A URL da série mudou. Migre de $sourceName " +
"para $sourceName para atualizar a URL."
SPANISH ->
"La URL de la serie ha cambiado. Migre de $sourceName a " +
"$sourceName para actualizar la URL."
else ->
"The URL of the series has changed. Migrate from $sourceName " +
"to $sourceName to update the URL."
}
val idNotFoundError: String = when (availableLang) {
BRAZILIAN_PORTUGUESE -> "Falha ao obter o ID do slug: "
SPANISH -> "No se pudo encontrar el ID para: "
else -> "Failed to get the ID for slug: "
}
companion object {
const val BRAZILIAN_PORTUGUESE = "pt-BR"
const val ENGLISH = "en"
const val SPANISH = "es"
val AVAILABLE_LANGS = arrayOf(BRAZILIAN_PORTUGUESE, ENGLISH, SPANISH)
}
}

View File

@ -11,12 +11,10 @@ class OmegaScans : HeanCms("Omega Scans", "https://omegascans.org", "en") {
.rateLimitHost(apiUrl.toHttpUrl(), 1)
.build()
override val useNewQueryEndpoint = true
// Site changed from MangaThemesia to HeanCms.
override val versionId = 2
override val coverPath = ""
override val useNewChapterEndpoint = true
override fun getGenreList() = listOf(
Genre("Romance", 1),

View File

@ -17,8 +17,6 @@ class TempleScan : HeanCms(
.rateLimit(1)
.build()
override val useNewQueryEndpoint = true
override val coverPath = ""
override val mangaSubDirectory = "comic"
override fun getGenreList() = listOf(

View File

@ -6,12 +6,18 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
class PerfScan : HeanCms("Perf Scan", "https://perf-scan.fr", "fr") {
override val client: OkHttpClient = super.client.newBuilder()
.rateLimitHost(apiUrl.toHttpUrl(), 1, 2)
.build()
override val coverPath: String = ""
override val useNewQueryEndpoint = true
init {
preferences.run {
if (contains("pref_url_map")) {
edit().remove("pref_url_map").apply()
}
}
}
override val slugStrategy = SlugStrategy.ID
override val useNewChapterEndpoint = true
}