2025-05-15 13:38:03 -04:00

271 lines
11 KiB
Kotlin

package exh.md.utils
import android.app.Application
import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.util.PkceUtil
import exh.md.dto.MangaAttributesDto
import exh.md.dto.MangaDataDto
import exh.source.getMainSource
import exh.util.dropBlank
import exh.util.floor
import exh.util.nullIfZero
import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.jsoup.parser.Parser
import tachiyomi.domain.source.service.SourceManager
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone
class MdUtil {
companion object {
const val cdnUrl = "https://uploads.mangadex.org"
const val baseUrl = "https://mangadex.org"
const val chapterSuffix = "/chapter/"
const val similarCacheMapping = "https://api.similarmanga.com/mapping/mdex2search.csv"
const val similarCacheMangas = "https://api.similarmanga.com/manga/"
const val similarBaseApi = "https://api.similarmanga.com/similar/"
const val groupSearchUrl = "$baseUrl/groups/0/1/"
const val reportUrl = "https://api.mangadex.network/report"
const val mdAtHomeTokenLifespan = 10 * 60 * 1000
const val mangaLimit = 20
/**
* Get the manga offset pages are 1 based, so subtract 1
*/
fun getMangaListOffset(page: Int): String = (mangaLimit * (page - 1)).toString()
val jsonParser =
Json {
isLenient = true
ignoreUnknownKeys = true
allowSpecialFloatingPointValues = true
useArrayPolymorphism = true
prettyPrint = true
}
private const val scanlatorSeparator = " & "
const val contentRatingSafe = "safe"
const val contentRatingSuggestive = "suggestive"
const val contentRatingErotica = "erotica"
const val contentRatingPornographic = "pornographic"
val validOneShotFinalChapters = listOf("0", "1")
val markdownLinksRegex = "\\[([^]]+)\\]\\(([^)]+)\\)".toRegex()
val markdownItalicBoldRegex = "\\*+\\s*([^\\*]*)\\s*\\*+".toRegex()
val markdownItalicRegex = "_+\\s*([^_]*)\\s*_+".toRegex()
fun buildMangaUrl(mangaUuid: String): String {
return "/manga/$mangaUuid"
}
// Get the ID from the manga url
fun getMangaId(url: String): String = url.trimEnd('/').substringAfterLast("/")
fun getChapterId(url: String) = url.substringAfterLast("/")
fun cleanDescription(string: String): String {
return Parser.unescapeEntities(string, false)
.substringBefore("\n---")
.replace(markdownLinksRegex, "$1")
.replace(markdownItalicBoldRegex, "$1")
.replace(markdownItalicRegex, "$1")
.trim()
}
fun getImageUrl(attr: String): String {
// Some images are hosted elsewhere
if (attr.startsWith("http")) {
return attr
}
return baseUrl + attr
}
fun getScanlators(scanlators: String?): Set<String> {
return scanlators?.split(scanlatorSeparator)?.dropBlank()?.toSet().orEmpty()
}
fun getScanlatorString(scanlators: Set<String>): String {
return scanlators.sorted().joinToString(scanlatorSeparator)
}
fun getMissingChapterCount(chapters: List<SChapter>, mangaStatus: Int): String? {
if (mangaStatus == SManga.COMPLETED) return null
val remove0ChaptersFromCount = chapters.distinctBy {
/*if (it.chapter_txt.isNotEmpty()) {
it.vol + it.chapter_txt
} else {*/
it.name
/*}*/
}.sortedByDescending { it.chapter_number }
remove0ChaptersFromCount.firstOrNull()?.let { chapter ->
val chpNumber = chapter.chapter_number.floor()
val allChapters = (1..chpNumber).toMutableSet()
remove0ChaptersFromCount.forEach {
allChapters.remove(it.chapter_number.floor())
}
if (allChapters.isEmpty()) return null
return allChapters.size.toString()
}
return null
}
val dateFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss+SSS", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
fun parseDate(dateAsString: String): Long =
dateFormatter.parse(dateAsString)?.time ?: 0
fun createMangaEntry(json: MangaDataDto, lang: String): SManga {
return SManga(
url = buildMangaUrl(json.id),
title = getTitleFromManga(json.attributes, lang),
thumbnail_url = json.relationships
.firstOrNull { relationshipDto -> relationshipDto.type == MdConstants.Types.coverArt }
?.attributes
?.fileName
?.let { coverFileName ->
cdnCoverUrl(json.id, coverFileName)
}.orEmpty(),
)
}
fun getTitleFromManga(json: MangaAttributesDto, lang: String): String {
return getFromLangMap(json.title.asMdMap(), lang, json.originalLanguage)
?: getAltTitle(json.altTitles, lang, json.originalLanguage)
?: json.title.asMdMap<String>()[json.originalLanguage]
?: json.altTitles.firstNotNullOfOrNull { it[json.originalLanguage] }
.orEmpty()
}
fun getFromLangMap(langMap: Map<String, String>, currentLang: String, originalLanguage: String): String? {
return langMap[currentLang]
?: langMap["en"]
?: if (originalLanguage == "ja") {
langMap["ja-ro"]
?: langMap["jp-ro"]
} else {
null
}
}
fun getAltTitle(langMaps: List<Map<String, String>>, currentLang: String, originalLanguage: String): String? {
return langMaps.firstNotNullOfOrNull { it[currentLang] }
?: langMaps.firstNotNullOfOrNull { it["en"] }
?: if (originalLanguage == "ja") {
langMaps.firstNotNullOfOrNull { it["ja-ro"] }
?: langMaps.firstNotNullOfOrNull { it["jp-ro"] }
} else {
null
}
}
fun cdnCoverUrl(dexId: String, fileName: String): String {
return "$cdnUrl/covers/$dexId/$fileName"
}
fun saveOAuth(preferences: TrackPreferences, mdList: MdList, oAuth: MALOAuth?) {
if (oAuth == null) {
preferences.trackToken(mdList).delete()
} else {
preferences.trackToken(mdList).set(jsonParser.encodeToString(oAuth))
}
}
fun loadOAuth(preferences: TrackPreferences, mdList: MdList): MALOAuth? {
return try {
jsonParser.decodeFromString<MALOAuth>(preferences.trackToken(mdList).get())
} catch (e: Exception) {
null
}
}
private var codeVerifier: String? = null
fun refreshTokenRequest(oauth: MALOAuth): Request {
val formBody = FormBody.Builder()
.add("client_id", MdConstants.Login.clientId)
.add("grant_type", MdConstants.Login.refreshToken)
.add("refresh_token", oauth.refreshToken)
.add("code_verifier", getPkceChallengeCode())
.add("redirect_uri", MdConstants.Login.redirectUri)
.build()
// Add the Authorization header manually as this particular
// request is called by the interceptor itself so it doesn't reach
// the part where the token is added automatically.
val headers = Headers.Builder()
.add("Authorization", "Bearer ${oauth.accessToken}")
.build()
return POST(MdApi.baseAuthUrl + MdApi.token, body = formBody, headers = headers)
}
fun getPkceChallengeCode(): String {
return codeVerifier ?: PkceUtil.generateCodeVerifier().also { codeVerifier = it }
}
fun getEnabledMangaDex(sourcePreferences: SourcePreferences = Injekt.get(), sourceManager: SourceManager = Injekt.get()): MangaDex? {
return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs ->
sourcePreferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero()
?.let { preferredMangaDexId ->
mangadexs.firstOrNull { it.id == preferredMangaDexId }
}
?: mangadexs.firstOrNull()
}
}
fun getEnabledMangaDexs(preferences: SourcePreferences, sourceManager: SourceManager = Injekt.get()): List<MangaDex> {
val languages = preferences.enabledLanguages().get()
val disabledSourceIds = preferences.disabledSources().get()
return sourceManager.getVisibleOnlineSources()
.asSequence()
.mapNotNull { it.getMainSource<MangaDex>() }
.filter { it.lang in languages }
.filterNot { it.id.toString() in disabledSourceIds }
.toList()
}
inline fun <reified T> encodeToBody(body: T): RequestBody {
return jsonParser.encodeToString(body)
.toRequestBody("application/json".toMediaType())
}
fun addAltTitleToDesc(description: String, altTitles: List<String>?): String {
return if (altTitles.isNullOrEmpty()) {
description
} else {
val altTitlesDesc = altTitles
.joinToString("\n", "${Injekt.get<Application>().getString(R.string.alt_titles)}:\n") { "$it" }
description + (if (description.isBlank()) "" else "\n\n") + Parser.unescapeEntities(altTitlesDesc, false)
}
}
}
}