Anchira: Add Entry Bundling (#1643)

* Anchira: Add Entry Bundling
Prefixing queries with `bundle:` will create a single SManga entry with results as chapters.

* Switch default bundle title to first entry's
Fix url used to check for bundles
Default page count of 1

* Enable filters on bundles

* Strip chapter number suffix from bundle title

* Convert RegEx to variable

* Convert RegEx constructor to top-level val
This commit is contained in:
BrutuZ 2024-03-03 00:13:52 -03:00 committed by Draff
parent 58b5aa2f3d
commit 2bb5ef9059
5 changed files with 155 additions and 167 deletions

View File

@ -1,7 +1,7 @@
ext {
extName = 'Anchira'
extClass = '.Anchira'
extVersionCode = 9
extVersionCode = 10
isNsfw = true
}

View File

@ -6,6 +6,7 @@ import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.createChapter
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.getPathFromUrl
import eu.kanade.tachiyomi.extension.en.anchira.AnchiraHelper.prepareTags
import eu.kanade.tachiyomi.network.GET
@ -23,6 +24,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.OkHttpClient
@ -33,6 +35,8 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.io.IOException
import java.util.concurrent.TimeUnit
import kotlin.math.ceil
import kotlin.math.min
class Anchira : HttpSource(), ConfigurableSource {
override val name = "Anchira"
@ -109,6 +113,23 @@ class Anchira : HttpSource(), ConfigurableSource {
fetchMangaDetails(manga).map {
MangasPage(listOf(it), false)
}
} else if (query.startsWith(SLUG_BUNDLE_PREFIX)) {
// bundle entries as chapters
val url = applyFilters(
page,
query.substringAfter(SLUG_BUNDLE_PREFIX),
filters,
).removeAllQueryParameters("page")
if (
url.build().queryParameter("sort") == "4"
) {
url.removeAllQueryParameters("sort")
}
val manga = SManga.create()
.apply { this.url = "?${url.build().query}" }
fetchMangaDetails(manga).map {
MangasPage(listOf(it), false)
}
} else {
// regular filtering without text search
client.newCall(searchMangaRequest(page, query, filters))
@ -116,29 +137,29 @@ class Anchira : HttpSource(), ConfigurableSource {
.map(::searchMangaParse)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
GET(applyFilters(page, query, filters).build(), headers)
private fun applyFilters(page: Int, query: String, filters: FilterList): HttpUrl.Builder {
val filterList = if (filters.isEmpty()) getFilterList() else filters
val trendingFilter = filterList.findInstance<TrendingFilter>()
val sortTrendingFilter = filters.findInstance<SortTrendingFilter>()
var url = libraryUrl.toHttpUrl().newBuilder()
url.addQueryParameter("page", page.toString())
if (trendingFilter?.state == true) {
val interval = when (sortTrendingFilter?.state) {
1 -> "3"
else -> ""
}
if (interval.isNotBlank()) url.addQueryParameter("interval", interval)
if (interval.isNotBlank()) url.setQueryParameter("interval", interval)
url = url.toString().replace("library", "trending").toHttpUrl()
.newBuilder()
return GET(url.build(), headers)
} else {
if (query.isNotBlank()) {
url.addQueryParameter("s", query)
url.setQueryParameter("s", query)
}
filters.forEach { filter ->
@ -154,7 +175,7 @@ class Anchira : HttpSource(), ConfigurableSource {
}
}
if (sum > 0) url.addQueryParameter("cat", sum.toString())
if (sum > 0) url.setQueryParameter("cat", sum.toString())
}
is SortFilter -> {
@ -166,8 +187,8 @@ class Anchira : HttpSource(), ConfigurableSource {
else -> ""
}
if (sort.isNotEmpty()) url.addQueryParameter("sort", sort)
if (filter.state?.ascending == true) url.addQueryParameter("order", "1")
if (sort.isNotEmpty()) url.setQueryParameter("sort", sort)
if (filter.state?.ascending == true) url.setQueryParameter("order", "1")
}
is FavoritesFilter -> {
@ -184,57 +205,103 @@ class Anchira : HttpSource(), ConfigurableSource {
else -> {}
}
}
return GET(url.build(), headers)
}
if (page > 1) {
url.setQueryParameter("page", page.toString())
}
return url
}
override fun searchMangaParse(response: Response) = latestUpdatesParse(response)
// Details
override fun mangaDetailsRequest(manga: SManga) =
GET("$libraryUrl/${getPathFromUrl(manga.url)}", headers)
override fun mangaDetailsParse(response: Response): SManga {
val data = json.decodeFromString<Entry>(response.body.string())
return SManga.create().apply {
url = "/g/${data.id}/${data.key}"
title = data.title
thumbnail_url =
"$cdnUrl/${data.id}/${data.key}/b/${data.thumbnailIndex + 1}"
artist = data.tags.filter { it.namespace == 1 }.joinToString(", ") { it.name }
author = data.tags.filter { it.namespace == 2 }.joinToString(", ") { it.name }
genre = prepareTags(data.tags, preferences.useTagGrouping)
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
status = SManga.COMPLETED
override fun mangaDetailsRequest(manga: SManga): Request {
return if (manga.url.startsWith("?")) {
GET(libraryUrl + manga.url, headers)
} else {
GET("$libraryUrl/${getPathFromUrl(manga.url)}", headers)
}
}
override fun getMangaUrl(manga: SManga) = if (preferences.openSource) {
val id = manga.url.split("/").reversed()[1].toInt()
anchiraData.find { it.id == id }?.url ?: "$baseUrl${manga.url}"
} else {
"$baseUrl${manga.url}"
override fun mangaDetailsParse(response: Response): SManga {
return if (response.request.url.pathSegments.count() == libraryUrl.toHttpUrl().pathSegments.count()) {
val manga = latestUpdatesParse(response).mangas.first()
val query = response.request.url.queryParameter("s")
val cleanTitle = CHAPTER_SUFFIX_RE.replace(manga.title, "").trim()
manga.apply {
url = "?${response.request.url.query}"
description = "Bundled from $query"
title = "[Bundle] $cleanTitle"
update_strategy = UpdateStrategy.ALWAYS_UPDATE
}
} else {
val data = json.decodeFromString<Entry>(response.body.string())
SManga.create().apply {
url = "/g/${data.id}/${data.key}"
title = data.title
thumbnail_url =
"$cdnUrl/${data.id}/${data.key}/b/${data.thumbnailIndex + 1}"
artist = data.tags.filter { it.namespace == 1 }.joinToString(", ") { it.name }
author = data.tags.filter { it.namespace == 2 }.joinToString(", ") { it.name }
genre = prepareTags(data.tags, preferences.useTagGrouping)
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
status = SManga.COMPLETED
}
}
}
override fun getMangaUrl(manga: SManga) =
if (preferences.openSource && !manga.url.startsWith("?")) {
val id = manga.url.split("/").reversed()[1].toInt()
anchiraData.find { it.id == id }?.url ?: "$baseUrl${manga.url}"
} else {
"$baseUrl${manga.url}"
}
// Chapter
override fun chapterListRequest(manga: SManga) =
GET("$libraryUrl/${getPathFromUrl(manga.url)}", headers)
override fun chapterListRequest(manga: SManga): Request {
return if (manga.url.startsWith("?")) {
GET(libraryUrl + manga.url, headers)
} else {
GET("$libraryUrl/${getPathFromUrl(manga.url)}", headers)
}
}
override fun chapterListParse(response: Response): List<SChapter> {
val data = json.decodeFromString<Entry>(response.body.string())
return listOf(
SChapter.create().apply {
url = "/g/${data.id}/${data.key}"
name = "Chapter"
date_upload = data.publishedAt * 1000
chapter_number = 1f
},
)
val chapterList = mutableListOf<SChapter>()
if (response.request.url.pathSegments.count() == libraryUrl.toHttpUrl().pathSegments.count()) {
var results = json.decodeFromString<LibraryResponse>(response.body.string())
val pages = min(5, ceil((results.total.toFloat() / results.limit)).toInt())
for (page in 1..pages) {
results.entries.forEach { data ->
chapterList.add(
createChapter(data, response, anchiraData),
)
}
if (page < pages) {
results = json.decodeFromString<LibraryResponse>(
client.newCall(
GET(
response.request.url.newBuilder()
.setQueryParameter("page", (page + 1).toString()).build(),
headers,
),
).execute().body.string(),
)
}
}
} else {
val data = json.decodeFromString<Entry>(response.body.string())
chapterList.add(
createChapter(data, response, anchiraData),
)
}
return chapterList
}
override fun getChapterUrl(chapter: SChapter) = "$baseUrl/g/${getPathFromUrl(chapter.url)}"
@ -295,14 +362,16 @@ class Anchira : HttpSource(), ConfigurableSource {
val openSourcePref = SwitchPreferenceCompat(screen.context).apply {
key = OPEN_SOURCE_PREF
title = "Open source website in WebView"
summary = "Enable to open the original source website of the gallery (if available) instead of Anchira."
summary =
"Enable to open the original source website of the gallery (if available) instead of Anchira."
setDefaultValue(false)
}
val useTagGrouping = SwitchPreferenceCompat(screen.context).apply {
key = USE_TAG_GROUPING
title = "Group tags"
summary = "Enable to group tags together by artist, circle, parody, magazine and general tags"
summary =
"Enable to group tags together by artist, circle, parody, magazine and general tags"
setDefaultValue(false)
}
@ -399,6 +468,7 @@ class Anchira : HttpSource(), ConfigurableSource {
companion object {
const val SLUG_SEARCH_PREFIX = "id:"
const val SLUG_BUNDLE_PREFIX = "bundle:"
private const val IMAGE_QUALITY_PREF = "image_quality"
private const val OPEN_SOURCE_PREF = "use_manga_source"
private const val USE_TAG_GROUPING = "use_tag_grouping"
@ -406,3 +476,5 @@ class Anchira : HttpSource(), ConfigurableSource {
"https://gist.githubusercontent.com/LetrixZ/2b559cc5829d1c221c701e02ecd81411/raw/data-v5.json"
}
}
val CHAPTER_SUFFIX_RE = Regex("(?<!20\\d\\d-)\\b[\\d.]{1,4}$")

View File

@ -3,15 +3,6 @@ package eu.kanade.tachiyomi.extension.en.anchira
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ListEntry(
val id: Int,
val key: String,
val title: String,
@SerialName("thumb_index") val thumbnailIndex: Int,
val tags: List<Tag> = emptyList(),
)
@Serializable
data class Tag(
var name: String,
@ -20,7 +11,7 @@ data class Tag(
@Serializable
data class LibraryResponse(
val entries: List<ListEntry> = emptyList(),
val entries: List<Entry> = emptyList(),
val total: Int,
val page: Int,
val limit: Int,
@ -30,11 +21,12 @@ data class LibraryResponse(
data class Entry(
val id: Int,
val key: String,
@SerialName("published_at") val publishedAt: Long,
@SerialName("published_at") val publishedAt: Long = 0L,
val title: String,
@SerialName("thumb_index") val thumbnailIndex: Int,
@SerialName("thumb_index") val thumbnailIndex: Int = 1,
val tags: List<Tag> = emptyList(),
val url: String? = null,
val pages: Int = 1,
)
@Serializable

View File

@ -1,5 +1,9 @@
package eu.kanade.tachiyomi.extension.en.anchira
import eu.kanade.tachiyomi.source.model.SChapter
import okhttp3.Response
import java.util.Locale
object AnchiraHelper {
fun getPathFromUrl(url: String) = "${url.split("/").reversed()[1]}/${url.split("/").last()}"
@ -25,4 +29,31 @@ object AnchiraHelper {
}
}
.joinToString(", ") { it }
fun createChapter(entry: Entry, response: Response, anchiraData: List<EntryKey>) =
SChapter.create().apply {
val ch =
CHAPTER_SUFFIX_RE.find(entry.title)?.value?.trim('.') ?: "1"
val source = anchiraData.find { it.id == entry.id }?.url
?: response.request.url.toString()
url = "/g/${entry.id}/${entry.key}"
name = "$ch. ${entry.title.removeSuffix(" $ch")}"
date_upload = entry.publishedAt * 1000
chapter_number = ch.toFloat()
scanlator = buildString {
append(
Regex("fakku|irodori|anchira").find(source)?.value.orEmpty()
.replaceFirstChar {
if (it.isLowerCase()) {
it.titlecase(
Locale.getDefault(),
)
} else {
it.toString()
}
},
)
append(" - ${entry.pages} pages")
}
}
}

View File

@ -1,107 +0,0 @@
package eu.kanade.tachiyomi.extension.en.anchira
object XXTEA {
private const val DELTA = -0x61c88647
@Suppress("NOTHING_TO_INLINE", "FunctionName")
private inline fun MX(sum: Int, y: Int, z: Int, p: Int, e: Int, k: IntArray): Int {
return (z.ushr(5) xor (y shl 2)) + (y.ushr(3) xor (z shl 4)) xor (sum xor y) + (k[p and 3 xor e] xor z)
}
private fun decrypt(data: ByteArray, key: ByteArray): ByteArray =
data.takeIf { it.isNotEmpty() }
?.let {
decrypt(data.toIntArray(false), key.fixKey().toIntArray(false))
.toByteArray(true)
} ?: data
fun decrypt(data: ByteArray, key: String): ByteArray? =
kotlin.runCatching { decrypt(data, key.toByteArray(Charsets.UTF_8)) }.getOrNull()
fun decryptToString(data: ByteArray, key: String): String? =
kotlin.runCatching { decrypt(data, key)?.toString(Charsets.UTF_8) }.getOrNull()
private fun decrypt(v: IntArray, k: IntArray): IntArray {
val n = v.size - 1
if (n < 1) {
return v
}
var p: Int
val q = 6 + 52 / (n + 1)
var z: Int
var y = v[0]
var sum = q * DELTA
var e: Int
while (sum != 0) {
e = sum.ushr(2) and 3
p = n
while (p > 0) {
z = v[p - 1]
v[p] -= MX(sum, y, z, p, e, k)
y = v[p]
p--
}
z = v[n]
v[0] -= MX(sum, y, z, p, e, k)
y = v[0]
sum -= DELTA
}
return v
}
private fun ByteArray.fixKey(): ByteArray {
if (size == 16) return this
val fixedKey = ByteArray(16)
if (size < 16) {
copyInto(fixedKey)
} else {
copyInto(fixedKey, endIndex = 16)
}
return fixedKey
}
private fun ByteArray.toIntArray(includeLength: Boolean): IntArray {
var n = if (size and 3 == 0) {
size.ushr(2)
} else {
size.ushr(2) + 1
}
val result: IntArray
if (includeLength) {
result = IntArray(n + 1)
result[n] = size
} else {
result = IntArray(n)
}
n = size
for (i in 0 until n) {
result[i.ushr(2)] =
result[i.ushr(2)] or (0x000000ff and this[i].toInt() shl (i and 3 shl 3))
}
return result
}
private fun IntArray.toByteArray(includeLength: Boolean): ByteArray? {
var n = size shl 2
if (includeLength) {
val m = this[size - 1]
n -= 4
if (m < n - 3 || m > n) {
return null
}
n = m
}
val result = ByteArray(n)
for (i in 0 until n) {
result[i] = this[i.ushr(2)].ushr(i and 3 shl 3).toByte()
}
return result
}
}