diff --git a/src/en/mangalix/build.gradle.kts b/src/en/mangalix/build.gradle.kts new file mode 100644 index 00000000000..39ddc2fc849 --- /dev/null +++ b/src/en/mangalix/build.gradle.kts @@ -0,0 +1,21 @@ +import io.github.keiyoushi.gradle.api.ContentWarning + +plugins { + alias(kei.plugins.extension) +} + +keiyoushi { + name = "MangaLix" + versionCode = 1 + contentWarning = ContentWarning.MIXED + libVersion = "1.6" + + source { + lang = "en" + baseUrl = "https://mangalix.com" + } + + deeplink { + path("/manga/..*") + } +} diff --git a/src/en/mangalix/res/mipmap-hdpi/ic_launcher.png b/src/en/mangalix/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000000..4e9c70ba4f8 Binary files /dev/null and b/src/en/mangalix/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/mangalix/res/mipmap-mdpi/ic_launcher.png b/src/en/mangalix/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000000..80810ee886f Binary files /dev/null and b/src/en/mangalix/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/mangalix/res/mipmap-xhdpi/ic_launcher.png b/src/en/mangalix/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000000..3f562c7acb3 Binary files /dev/null and b/src/en/mangalix/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/mangalix/res/mipmap-xxhdpi/ic_launcher.png b/src/en/mangalix/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000000..84bb68abd3f Binary files /dev/null and b/src/en/mangalix/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/mangalix/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/mangalix/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000000..115d322ad4c Binary files /dev/null and b/src/en/mangalix/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Dto.kt b/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Dto.kt new file mode 100644 index 00000000000..4b04fa4c96f --- /dev/null +++ b/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Dto.kt @@ -0,0 +1,74 @@ +package eu.kanade.tachiyomi.extension.en.mangalix + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset +import java.util.Locale + +@Serializable +internal class MangaDto( + val slug: String, + val title: String, + val description: String, + val coverImage: String, + val author: String, + val status: String, + val rating: Double, + val releaseYear: Double, + val genres: List, + val latestChapter: LatestChapterDto?, +) { + val latestTimestamp: Long + get() = latestChapter?.releaseDate.toMangaTimestamp() + + fun toSManga(baseUrl: String): SManga = SManga.create().apply { + url = slug + title = this@MangaDto.title + thumbnail_url = coverImage.takeIf { it.isNotBlank() }?.let { + when { + it.startsWith("http://", ignoreCase = true) || it.startsWith("https://", ignoreCase = true) -> it + it.startsWith("//") -> "https:$it" + else -> "${baseUrl.trimEnd('/')}/${it.trimStart('/')}" + } + } + author = this@MangaDto.author.takeIf { it.isNotBlank() } + description = this@MangaDto.description.takeIf { it.isNotBlank() } + genre = genres.takeIf { it.isNotEmpty() }?.joinToString() + status = this@MangaDto.status.toMangaStatus() + initialized = true + } +} + +@Serializable +internal class LatestChapterDto( + val releaseDate: String?, +) + +internal class ChapterDto( + val id: String, + val number: Double, + val title: String, + val pages: List, + val releaseDate: String? = null, +) + +internal fun String?.toMangaTimestamp(): Long = this?.let { value -> + runCatching { Instant.parse(value).toEpochMilli() } + .recoverCatching { LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() } + .getOrDefault(0L) +} ?: 0L + +private fun String.toMangaStatus(): Int = when ( + lowercase(Locale.ROOT) + .trim() + .replace("-", " ") + .replace(Regex("""\s+"""), " ") +) { + "ongoing", "publishing", "releasing", "active" -> SManga.ONGOING + "completed", "complete", "finished" -> SManga.COMPLETED + "hiatus", "on hiatus", "paused" -> SManga.ON_HIATUS + "cancelled", "canceled", "dropped", "axed", "discontinued" -> SManga.CANCELLED + else -> SManga.UNKNOWN +} diff --git a/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Filters.kt b/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Filters.kt new file mode 100644 index 00000000000..6683ae4e431 --- /dev/null +++ b/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Filters.kt @@ -0,0 +1,138 @@ +package eu.kanade.tachiyomi.extension.en.mangalix + +import eu.kanade.tachiyomi.source.model.Filter +import java.util.Locale + +internal class StatusFilter : + Filter.Select( + "Status", + STATUS_OPTIONS.map { it.first }.toTypedArray(), + ) { + val selected: String? + get() = STATUS_OPTIONS[state].second.takeIf { it.isNotEmpty() } + + fun matches(status: String): Boolean = selected?.let { + status.normalizedStatus() == it + } ?: true + + companion object { + private val STATUS_OPTIONS = listOf( + "All" to "", + "Ongoing" to "ongoing", + "Completed" to "completed", + "Hiatus" to "hiatus", + ) + } +} + +internal class SortFilter( + selection: Selection = Selection(0, false), +) : Filter.Sort( + "Sort By", + SORT_OPTIONS.map { it.first }.toTypedArray(), + selection, +) { + val selected: String + get() = SORT_OPTIONS[state?.index ?: 0].second + + val ascending: Boolean + get() = state?.ascending ?: false + + companion object { + const val DEFAULT = "default" + const val LATEST = "latest" + const val RATING = "rating" + const val TITLE = "title" + const val RELEASE_YEAR = "release_year" + + private val SORT_OPTIONS = listOf( + "Default" to DEFAULT, + "Latest Update" to LATEST, + "Release Year" to RELEASE_YEAR, + "Rating" to RATING, + "Title" to TITLE, + ) + } +} + +internal class GenreFilter : + Filter.Group( + "Genres", + GENRES.map(::GenreOption), + ) { + val selectedGenres: Set + get() = state.filter { it.state }.mapTo(linkedSetOf()) { it.name } + + fun matches(genres: List): Boolean { + val selected = selectedGenres.mapTo(hashSetOf()) { it.normalizedGenre() } + if (selected.isEmpty()) return true + + val available = genres.mapTo(hashSetOf()) { it.normalizedGenre() } + return available.containsAll(selected) + } + + companion object { + private val GENRES = listOf( + "4-Koma", + "Action", + "Adventure", + "Comedy", + "Dark Fantasy", + "Drama", + "Ecchi", + "Family", + "Fantasy", + "Harem", + "Historical", + "Horror", + "Isekai", + "Magic", + "Manhwa", + "Martial Arts", + "Mature", + "Mecha", + "Military", + "Murim", + "Mystery", + "Parody", + "Psychological", + "Regression", + "Reincarnation", + "Romance", + "School Life", + "Sci-Fi", + "Seinen", + "Shoujo", + "Shounen", + "Slice of Life", + "Sports", + "Supernatural", + "Survival", + "System", + "Thriller", + "Tragedy", + "Vampire", + "Webtoon", + ) + } +} + +internal class GenreOption(name: String) : Filter.CheckBox(name) + +private fun String.normalizedStatus(): String = when (normalizedKey()) { + "ongoing", "publishing", "releasing", "active" -> "ongoing" + "completed", "complete", "finished" -> "completed" + "hiatus", "onhiatus", "paused" -> "hiatus" + else -> normalizedKey() +} + +private fun String.normalizedGenre(): String = when (val value = normalizedKey()) { + "school" -> "schoollife" + "shojo" -> "shoujo" + "shonen" -> "shounen" + "webtoons" -> "webtoon" + else -> value +} + +private fun String.normalizedKey(): String = lowercase(Locale.ROOT) + .filter(Char::isLetterOrDigit) diff --git a/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/JsLiteralParser.kt b/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/JsLiteralParser.kt new file mode 100644 index 00000000000..d13b40a36ce --- /dev/null +++ b/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/JsLiteralParser.kt @@ -0,0 +1,189 @@ +package eu.kanade.tachiyomi.extension.en.mangalix + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.io.IOException + +internal class JsLiteralParser( + private val source: String, + startIndex: Int, +) { + private var position = startIndex + + fun parse(): JsonElement { + skipWhitespace() + return parseValue() + } + + private fun parseValue(): JsonElement { + skipWhitespace() + return when (peek()) { + '{' -> parseObject() + '[' -> parseArray() + '\'', '"' -> JsonPrimitive(parseString()) + '-', '+', in '0'..'9' -> parseNumber() + else -> parseKeyword() + } + } + + private fun parseObject(): JsonObject { + expect('{') + skipWhitespace() + + val values = linkedMapOf() + if (consume('}')) return JsonObject(values) + + while (true) { + skipWhitespace() + val key = when (peek()) { + '\'', '"' -> parseString() + else -> parseIdentifier() + } + + skipWhitespace() + expect(':') + values[key] = parseValue() + + skipWhitespace() + if (consume('}')) break + expect(',') + skipWhitespace() + if (consume('}')) break + } + + return JsonObject(values) + } + + private fun parseArray(): JsonArray { + expect('[') + skipWhitespace() + + val values = mutableListOf() + if (consume(']')) return JsonArray(values) + + while (true) { + values += parseValue() + + skipWhitespace() + if (consume(']')) break + expect(',') + skipWhitespace() + if (consume(']')) break + } + + return JsonArray(values) + } + + private fun parseString(): String { + val quote = next() + val result = StringBuilder() + + while (position < source.length) { + val char = next() + if (char == quote) return result.toString() + if (char != '\\') { + result.append(char) + continue + } + + if (position >= source.length) fail("Unterminated escape sequence") + when (val escaped = next()) { + '\'', '"', '\\', '/' -> result.append(escaped) + 'b' -> result.append('\b') + 'f' -> result.append('\u000C') + 'n' -> result.append('\n') + 'r' -> result.append('\r') + 't' -> result.append('\t') + 'v' -> result.append('\u000B') + '0' -> result.append('\u0000') + 'x' -> result.append(readHex(2).toChar()) + 'u' -> { + if (consume('{')) { + val end = source.indexOf('}', position) + if (end == -1) fail("Unterminated Unicode escape") + val codePoint = source.substring(position, end).toIntOrNull(16) + ?: fail("Invalid Unicode escape") + result.appendCodePoint(codePoint) + position = end + 1 + } else { + result.append(readHex(4).toChar()) + } + } + '\n' -> Unit + '\r' -> consume('\n') + else -> result.append(escaped) + } + } + + fail("Unterminated string") + } + + private fun parseNumber(): JsonPrimitive { + val start = position + if (peek() == '+' || peek() == '-') position++ + while (position < source.length && source[position].isDigit()) position++ + if (position < source.length && source[position] == '.') { + position++ + while (position < source.length && source[position].isDigit()) position++ + } + if (position < source.length && source[position] in "eE") { + position++ + if (position < source.length && source[position] in "+-") position++ + while (position < source.length && source[position].isDigit()) position++ + } + + val number = source.substring(start, position) + return number.toLongOrNull()?.let(::JsonPrimitive) + ?: number.toDoubleOrNull()?.let(::JsonPrimitive) + ?: fail("Invalid number") + } + + private fun parseKeyword(): JsonElement = when (val value = parseIdentifier()) { + "true" -> JsonPrimitive(true) + "false" -> JsonPrimitive(false) + "null", "undefined" -> JsonNull + else -> fail("Unsupported value '$value'") + } + + private fun parseIdentifier(): String { + val start = position + while (position < source.length) { + val char = source[position] + if (!char.isLetterOrDigit() && char != '_' && char != '$') break + position++ + } + if (position == start) fail("Expected identifier") + return source.substring(start, position) + } + + private fun readHex(length: Int): Int { + if (position + length > source.length) fail("Incomplete hexadecimal escape") + val value = source.substring(position, position + length).toIntOrNull(16) + ?: fail("Invalid hexadecimal escape") + position += length + return value + } + + private fun skipWhitespace() { + while (position < source.length && source[position].isWhitespace()) position++ + } + + private fun peek(): Char = source.getOrNull(position) ?: fail("Unexpected end of input") + + private fun next(): Char = peek().also { position++ } + + private fun expect(expected: Char) { + if (!consume(expected)) fail("Expected '$expected'") + } + + private fun consume(expected: Char): Boolean { + if (source.getOrNull(position) != expected) return false + position++ + return true + } + + private fun fail(message: String): Nothing = throw IOException("$message at position $position") +} diff --git a/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Mangalix.kt b/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Mangalix.kt new file mode 100644 index 00000000000..bbec5900ff4 --- /dev/null +++ b/src/en/mangalix/src/eu/kanade/tachiyomi/extension/en/mangalix/Mangalix.kt @@ -0,0 +1,285 @@ +package eu.kanade.tachiyomi.extension.en.mangalix + +import android.util.JsonReader +import android.util.JsonToken +import eu.kanade.tachiyomi.network.GET +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.model.SMangaUpdate +import eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.annotation.Source +import keiyoushi.network.get +import keiyoushi.network.rateLimit +import keiyoushi.source.KeiSource +import keiyoushi.utils.firstInstanceOrNull +import keiyoushi.utils.parseAs +import keiyoushi.utils.string +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.CacheControl +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.io.InputStreamReader +import java.util.Locale +import java.util.zip.GZIPInputStream +import kotlin.time.Duration.Companion.minutes + +@Source +abstract class Mangalix : KeiSource() { + + override fun OkHttpClient.Builder.configureClient(): OkHttpClient.Builder = apply { + val host = baseUrl.toHttpUrl().host + rateLimit(2) { it.host == host } + } + + private val archiveHeaders: Headers + get() = headersBuilder() + .set("Accept", "application/gzip") + .set("Accept-Encoding", "identity") + .build() + + private val imageHeaders: Headers + get() = headersBuilder() + .set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + .build() + + override suspend fun getPopularManga(page: Int): MangasPage = MangasPage( + loadCatalog().map { it.toSManga(baseUrl) }, + hasNextPage = false, + ) + + override suspend fun getLatestUpdates(page: Int): MangasPage = MangasPage( + loadCatalog() + .sortedByDescending(MangaDto::latestTimestamp) + .map { it.toSManga(baseUrl) }, + hasNextPage = false, + ) + + override suspend fun getSearchMangaList(page: Int, query: String, filters: FilterList): MangasPage { + val statusFilter = filters.firstInstanceOrNull() + val sortFilter = filters.firstInstanceOrNull() + val genreFilter = filters.firstInstanceOrNull() + val terms = query.trim().lowercase(Locale.ROOT) + .split(WHITESPACE_REGEX) + .filter(String::isNotEmpty) + + var result = loadCatalog() + .filter { manga -> terms.isEmpty() || manga.matchesTerms(terms) } + .filter { manga -> statusFilter?.matches(manga.status) != false } + .filter { manga -> genreFilter?.matches(manga.genres) != false } + + result = when (sortFilter?.selected) { + SortFilter.LATEST -> result.sortedByDescending(MangaDto::latestTimestamp) + SortFilter.RATING -> result.sortedByDescending(MangaDto::rating) + SortFilter.TITLE -> result.sortedByDescending { it.title.lowercase(Locale.ROOT) } + SortFilter.RELEASE_YEAR -> result.sortedByDescending(MangaDto::releaseYear) + else -> result + } + + if (sortFilter?.ascending == true) result = result.reversed() + + return MangasPage(result.map { it.toSManga(baseUrl) }, hasNextPage = false) + } + + override fun getFilterList(data: JsonElement?): FilterList = FilterList( + StatusFilter(), + SortFilter(), + GenreFilter(), + ) + + override suspend fun getMangaByUrl(url: HttpUrl): SManga? { + if (url.host != baseUrl.toHttpUrl().host) return null + if (url.pathSegments.getOrNull(0) != "manga") return null + val slug = url.pathSegments.getOrNull(1) ?: return null + + return loadCatalog().firstOrNull { it.slug == slug }?.toSManga(baseUrl) + } + + override fun getMangaUrl(manga: SManga): String = "$baseUrl/manga/${manga.url}" + + override suspend fun fetchMangaUpdate( + manga: SManga, + chapters: List, + fetchDetails: Boolean, + fetchChapters: Boolean, + ): SMangaUpdate = coroutineScope { + val slug = manga.url + + val updatedManga = if (fetchDetails) { + async { + loadCatalog().firstOrNull { it.slug == slug }?.toSManga(baseUrl) ?: manga + } + } else { + null + } + + val updatedChapters = if (fetchChapters) { + async { + readChapters(slug).distinctBy { it.id to it.number }.map { chapter -> + SChapter.create().apply { + val number = chapter.number.toString().removeSuffix(".0") + url = chapter.id + memo = buildJsonObject { + put("slug", slug) + put("number", number) + } + name = chapter.title.ifBlank { "Chapter $number" } + chapter_number = chapter.number.toFloat() + date_upload = chapter.releaseDate.toMangaTimestamp() + } + } + } + } else { + null + } + + SMangaUpdate( + manga = updatedManga?.await() ?: manga, + chapters = updatedChapters?.await() ?: chapters, + ) + } + + override val supportsRelatedMangas = false + + override suspend fun fetchRelatedMangaList(manga: SManga): List = throw UnsupportedOperationException() + + override fun getChapterUrl(chapter: SChapter): String { + val slug = chapter.memo["slug"]!!.string + val number = chapter.memo["number"]!!.string + return "$baseUrl/manga/$slug/chapter/$number" + } + + override suspend fun getPageList(chapter: SChapter): List { + val slug = chapter.memo["slug"]?.string ?: throw Exception("Refresh chapter list") + val number = chapter.memo["number"]!!.string.toDouble() + val chapterId = chapter.url + + return readChapters(slug) + .firstOrNull { it.id == chapterId && it.number == number } + ?.pages + .orEmpty() + .mapIndexed { index, imageUrl -> + Page(index, imageUrl = imageUrl.resolveImageUrl()) + } + } + + override fun imageRequest(page: Page): Request = GET(page.imageUrl!!, imageHeaders) + + private suspend fun loadCatalog(): List { + val document = client.get(baseUrl).asJsoup() + val scriptUrl = document.selectFirst("script[type=module][src*=assets/main-], script[src*=assets/main-]") + ?.absUrl("src") + ?.takeIf(String::isNotBlank) + ?: throw IOException("Main script not found") + + val script = client.get(scriptUrl).body.string() + + CATALOG_START_REGEX.findAll(script).forEach { match -> + val element = runCatching { + JsLiteralParser(script, match.range.first).parse() + }.getOrNull() ?: return@forEach + val candidate = runCatching { + element.parseAs>() + }.getOrNull() ?: return@forEach + + if (candidate.isNotEmpty() && candidate.all { it.slug.isNotBlank() && it.title.isNotBlank() }) { + return candidate.distinctBy(MangaDto::slug) + } + } + + throw IOException("Manga catalog not found") + } + + private fun MangaDto.matchesTerms(terms: List): Boolean { + val searchable = "$title $author $slug".lowercase(Locale.ROOT) + return terms.all(searchable::contains) + } + + private suspend fun readChapters(slug: String): List = client.get("$baseUrl/chapters.json.gz", archiveHeaders, ARCHIVE_CACHE_CONTROL).use { response -> + JsonReader(InputStreamReader(GZIPInputStream(response.body.byteStream()), Charsets.UTF_8)).use { reader -> + var chapters: List = emptyList() + reader.beginObject() + while (reader.hasNext()) { + if (reader.nextName() == slug) { + chapters = reader.readChapterArray() + break + } + reader.skipValue() + } + chapters + } + } + + private fun JsonReader.readChapterArray(): List { + val chapters = mutableListOf() + beginArray() + while (hasNext()) { + beginObject() + var id = "" + var number = 0.0 + var title = "" + var releaseDate: String? = null + var pages = emptyList() + + while (hasNext()) { + when (nextName()) { + "id" -> id = nextString() + "number" -> number = nextDouble() + "title" -> title = nextString() + "releaseDate" -> releaseDate = nextNullableString() + "pages" -> pages = readStringArray() + else -> skipValue() + } + } + endObject() + if (id.isNotBlank()) { + chapters += ChapterDto(id, number, title, pages, releaseDate) + } + } + endArray() + return chapters + } + + private fun JsonReader.readStringArray(): List { + val values = mutableListOf() + beginArray() + while (hasNext()) values += nextString() + endArray() + return values + } + + private fun JsonReader.nextNullableString(): String? = if (peek() == JsonToken.NULL) { + nextNull() + null + } else { + nextString() + } + + private fun String.resolveImageUrl(): String = when { + startsWith("\$TEMP") -> replaceFirst("\$TEMP", "https://temp.compsci88.com") + startsWith("\$HOT") -> replaceFirst("\$HOT", "https://scans-hot.planeptune.us") + startsWith("\$LST") -> replaceFirst("\$LST", "https://scans.lastation.us") + startsWith("\$LOW") -> replaceFirst("\$LOW", "https://official.lowee.us") + startsWith("\$MFK") -> replaceFirst("\$MFK", "https://images.mangafreak.me") + startsWith("/cdn-readmanga/") -> "https://cdn.readmanga.cc${this.removePrefix("/cdn-readmanga")}" + startsWith("//") -> "https:$this" + startsWith("/") -> "$baseUrl$this" + else -> this + } + + companion object { + private val ARCHIVE_CACHE_CONTROL = CacheControl.Builder().maxAge(30.minutes).build() + private val CATALOG_START_REGEX = Regex("""\[\{id\s*:""") + private val WHITESPACE_REGEX = Regex("""\s+""") + } +}