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:
parent
58b5aa2f3d
commit
2bb5ef9059
@ -1,7 +1,7 @@
|
||||
ext {
|
||||
extName = 'Anchira'
|
||||
extClass = '.Anchira'
|
||||
extVersionCode = 9
|
||||
extVersionCode = 10
|
||||
isNsfw = true
|
||||
}
|
||||
|
||||
|
@ -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}$")
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user