Cubari: simplify url handling and search (#11216)

* migrate to keiyoushi.utils.parseAs

* Refactor: Simplify URL handling

- Pass URLs directly to search instead of pre-parsing in CubariUrlActivity
- Remove custom `cubari:` prefix and parser
- Handle direct URL searching in the main source file
- Add support for Gist URLs
- Remove redundant intent filters from AndroidManifest

* bump and fix useragent

* fix url

* update useragent

* trailing comma
This commit is contained in:
AwkwardPeak7 2025-10-26 12:03:15 +05:00 committed by Draff
parent 775d39331e
commit 5a004d08f5
Signed by: Draff
GPG Key ID: E8A89F3211677653
5 changed files with 106 additions and 146 deletions

View File

@ -32,19 +32,6 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:host="*.guya.moe" />
<data android:host="guya.moe" />
<data
android:pathPattern="/proxy/..*"
android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:host="*.imgur.com" /> <data android:host="*.imgur.com" />
<data android:host="imgur.com" /> <data android:host="imgur.com" />

View File

@ -20,6 +20,6 @@ If you've setup the Remote Storage via WebView the Recent tab shows your recent,
You can visit the [Cubari](https://cubari.moe/) website for for more information. You can visit the [Cubari](https://cubari.moe/) website for for more information.
### How do I add a gallery to Cubari? ### How do I add a gallery to Cubari?
You can directly open a imgur or Cubari link in the extension. You can directly open a imgur or Cubari link in the extension or paste the url in cubari browse
[Uncomment this if needed]: <> (## Guides) [Uncomment this if needed]: <> (## Guides)

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Cubari' extName = 'Cubari'
extClass = '.CubariFactory' extClass = '.CubariFactory'
extVersionCode = 25 extVersionCode = 26
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -1,7 +1,7 @@
package eu.kanade.tachiyomi.extension.all.cubari package eu.kanade.tachiyomi.extension.all.cubari
import android.app.Application
import android.os.Build import android.os.Build
import android.util.Base64
import eu.kanade.tachiyomi.AppInfo import eu.kanade.tachiyomi.AppInfo
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservable import eu.kanade.tachiyomi.network.asObservable
@ -12,7 +12,7 @@ import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.json.Json import keiyoushi.utils.parseAs
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean import kotlinx.serialization.json.boolean
@ -20,23 +20,18 @@ import kotlinx.serialization.json.double
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
open class Cubari(override val lang: String) : HttpSource() { class Cubari(override val lang: String) : HttpSource() {
final override val name = "Cubari" override val name = "Cubari"
final override val baseUrl = "https://cubari.moe" override val baseUrl = "https://cubari.moe"
final override val supportsLatest = true override val supportsLatest = true
private val json: Json by injectLazy()
override val client = network.cloudflareClient.newBuilder() override val client = network.cloudflareClient.newBuilder()
.addInterceptor { chain -> .addInterceptor { chain ->
@ -48,18 +43,17 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
.build() .build()
override fun headersBuilder() = Headers.Builder().apply { private val cubariHeaders = super.headersBuilder()
add( .set(
"User-Agent", "User-Agent",
"(Android ${Build.VERSION.RELEASE}; " + "(Android ${Build.VERSION.RELEASE}; " +
"${Build.MANUFACTURER} ${Build.MODEL}) " + "${Build.MANUFACTURER} ${Build.MODEL}) " +
"Tachiyomi/${AppInfo.getVersionName()} " + "Tachiyomi/${AppInfo.getVersionName()} ${Build.ID} " +
Build.ID, "Keiyoushi",
) ).build()
}
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int): Request {
return GET("$baseUrl/", headers) return GET("$baseUrl/", cubariHeaders)
} }
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> { override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
@ -72,12 +66,12 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
override fun latestUpdatesParse(response: Response): MangasPage { override fun latestUpdatesParse(response: Response): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonArray val result = response.parseAs<JsonArray>()
return parseMangaList(result, SortType.UNPINNED) return parseMangaList(result, SortType.UNPINNED)
} }
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
return GET("$baseUrl/", headers) return GET("$baseUrl/", cubariHeaders)
} }
override fun fetchPopularManga(page: Int): Observable<MangasPage> { override fun fetchPopularManga(page: Int): Observable<MangasPage> {
@ -90,19 +84,22 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonArray val result = response.parseAs<JsonArray>()
return parseMangaList(result, SortType.PINNED) return parseMangaList(result, SortType.PINNED)
} }
override fun fetchMangaDetails(manga: SManga): Observable<SManga> { override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
return client.newCall(chapterListRequest(manga)) return client.newCall(mangaDetailsRequest(manga))
.asObservableSuccess() .asObservableSuccess()
.map { response -> mangaDetailsParse(response, manga) } .map { response -> mangaDetailsParse(response, manga) }
} }
// Called when the series is loaded, or when opening in browser override fun getMangaUrl(manga: SManga): String {
return "$baseUrl${manga.url}"
}
override fun mangaDetailsRequest(manga: SManga): Request { override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$baseUrl${manga.url}", headers) return chapterListRequest(manga)
} }
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
@ -110,7 +107,7 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
private fun mangaDetailsParse(response: Response, manga: SManga): SManga { private fun mangaDetailsParse(response: Response, manga: SManga): SManga {
val result = json.parseToJsonElement(response.body.string()).jsonObject val result = response.parseAs<JsonObject>()
return parseManga(result, manga) return parseManga(result, manga)
} }
@ -126,17 +123,16 @@ open class Cubari(override val lang: String) : HttpSource() {
val source = urlComponents[2] val source = urlComponents[2]
val slug = urlComponents[3] val slug = urlComponents[3]
return GET("$baseUrl/read/api/$source/series/$slug/", headers) return GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders)
} }
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
throw Exception("Unused") throw UnsupportedOperationException()
} }
// Called after the request // Called after the request
private fun chapterListParse(response: Response, manga: SManga): List<SChapter> { private fun chapterListParse(response: Response, manga: SManga): List<SChapter> {
val res = response.body.string() return parseChapterList(response, manga)
return parseChapterList(res, manga)
} }
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
@ -161,21 +157,20 @@ open class Cubari(override val lang: String) : HttpSource() {
override fun pageListRequest(chapter: SChapter): Request { override fun pageListRequest(chapter: SChapter): Request {
return when { return when {
chapter.url.contains("/chapter/") -> { chapter.url.contains("/chapter/") -> {
GET("$baseUrl${chapter.url}", headers) GET("$baseUrl${chapter.url}", cubariHeaders)
} }
else -> { else -> {
val url = chapter.url.split("/") val url = chapter.url.split("/")
val source = url[2] val source = url[2]
val slug = url[3] val slug = url[3]
GET("$baseUrl/read/api/$source/series/$slug/", headers) GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders)
} }
} }
} }
private fun directPageListParse(response: Response): List<Page> { private fun directPageListParse(response: Response): List<Page> {
val res = response.body.string() val pages = response.parseAs<JsonArray>()
val pages = json.parseToJsonElement(res).jsonArray
return pages.mapIndexed { i, jsonEl -> return pages.mapIndexed { i, jsonEl ->
val page = if (jsonEl is JsonObject) { val page = if (jsonEl is JsonObject) {
@ -189,7 +184,7 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List<Page> { private fun seriesJsonPageListParse(response: Response, chapter: SChapter): List<Page> {
val jsonObj = json.parseToJsonElement(response.body.string()).jsonObject val jsonObj = response.parseAs<JsonObject>()
val groups = jsonObj["groups"]!!.jsonObject val groups = jsonObj["groups"]!!.jsonObject
val groupMap = groups.entries.associateBy({ it.value.jsonPrimitive.content.ifEmpty { "default" } }, { it.key }) val groupMap = groups.entries.associateBy({ it.value.jsonPrimitive.content.ifEmpty { "default" } }, { it.key })
val chapterScanlator = chapter.scanlator ?: "default" // workaround for "" as group causing NullPointerException (#13772) val chapterScanlator = chapter.scanlator ?: "default" // workaround for "" as group causing NullPointerException (#13772)
@ -222,23 +217,29 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
} }
// Stub
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
throw Exception("Unused") throw UnsupportedOperationException()
} }
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return when { return when {
query.startsWith(PROXY_PREFIX) -> { // handle direct links or old cubari:source/id format
val trimmedQuery = query.removePrefix(PROXY_PREFIX) query.startsWith("https://") || query.startsWith("cubari:") -> {
val (source, slug) = deepLinkHandler(query)
// Only tag for recently read on search // Only tag for recently read on search
client.newBuilder() client.newBuilder()
.addInterceptor(RemoteStorageUtils.TagInterceptor()) .addInterceptor(RemoteStorageUtils.TagInterceptor())
.build() .build()
.newCall(proxySearchRequest(trimmedQuery)) .newCall(GET("$baseUrl/read/api/$source/series/$slug/", cubariHeaders))
.asObservableSuccess() .asObservableSuccess()
.map { response -> .map { response ->
proxySearchParse(response, trimmedQuery) val result = response.parseAs<JsonObject>()
val manga = SManga.create().apply {
url = "/read/$source/$slug"
}
val mangaList = listOf(parseManga(result, manga))
MangasPage(mangaList, false)
} }
} }
else -> { else -> {
@ -259,18 +260,57 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
return GET("$baseUrl/", headers) return GET("$baseUrl/", cubariHeaders)
} }
private fun proxySearchRequest(query: String): Request { private fun deepLinkHandler(query: String): Pair<String, String> {
try { return if (query.startsWith("cubari:")) { // legacy cubari:source/slug format
val queryFragments = query.split("/") val queryFragments = query.substringAfter("cubari:").split("/", limit = 2)
val source = queryFragments[0] queryFragments[0] to queryFragments[1]
val slug = queryFragments[1] } else { // direct url searching
val url = query.toHttpUrl()
val host = url.host
val pathSegments = url.pathSegments
return GET("$baseUrl/read/api/$source/series/$slug/", headers) if (
} catch (e: Exception) { host.endsWith("imgur.com") &&
throw Exception(SEARCH_FALLBACK_MSG) pathSegments.size >= 2 &&
pathSegments[0] in listOf("a", "gallery")
) {
"imgur" to pathSegments[1]
} else if (
host.endsWith("reddit.com") &&
pathSegments.size >= 2 &&
pathSegments[0] == "gallery"
) {
"reddit" to pathSegments[1]
} else if (
host == "imgchest.com" &&
pathSegments.size >= 2 &&
pathSegments[0] == "p"
) {
"imgchest" to pathSegments[1]
} else if (
host.endsWith("catbox.moe") &&
pathSegments.size >= 2 &&
pathSegments[0] == "c"
) {
"catbox" to pathSegments[1]
} else if (
host.endsWith("cubari.moe") &&
pathSegments.size >= 3
) {
pathSegments[1] to pathSegments[2]
} else if (
host.endsWith(".githubusercontent.com")
) {
val src = host.substringBefore(".")
val path = url.encodedPath
"gist" to Base64.encodeToString("$src$path".toByteArray(), Base64.NO_PADDING)
} else {
throw Exception(SEARCH_FALLBACK_MSG)
}
} }
} }
@ -279,7 +319,7 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
private fun searchMangaParse(response: Response, query: String): MangasPage { private fun searchMangaParse(response: Response, query: String): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonArray val result = response.parseAs<JsonArray>()
val filterList = result.asSequence() val filterList = result.asSequence()
.map { it as JsonObject } .map { it as JsonObject }
@ -289,23 +329,14 @@ open class Cubari(override val lang: String) : HttpSource() {
return parseMangaList(JsonArray(filterList), SortType.ALL) return parseMangaList(JsonArray(filterList), SortType.ALL)
} }
private fun proxySearchParse(response: Response, query: String): MangasPage {
val result = json.parseToJsonElement(response.body.string()).jsonObject
return parseSearchList(result, query)
}
// ------------- Helpers and whatnot --------------- // ------------- Helpers and whatnot ---------------
private val volumeNotSpecifiedTerms = setOf("Uncategorized", "null", "") private val volumeNotSpecifiedTerms = setOf("Uncategorized", "null", "")
private fun parseChapterList(payload: String, manga: SManga): List<SChapter> { private fun parseChapterList(response: Response, manga: SManga): List<SChapter> {
val jsonObj = json.parseToJsonElement(payload).jsonObject val jsonObj = response.parseAs<JsonObject>()
val groups = jsonObj["groups"]!!.jsonObject val groups = jsonObj["groups"]!!.jsonObject
val chapters = jsonObj["chapters"]!!.jsonObject val chapters = jsonObj["chapters"]!!.jsonObject
val seriesSlug = jsonObj["slug"]!!.jsonPrimitive.content
val seriesPrefs = Injekt.get<Application>().getSharedPreferences("source_${id}_updateTime:$seriesSlug", 0)
val seriesPrefsEditor = seriesPrefs.edit()
val chapterList = chapters.entries.flatMap { chapterEntry -> val chapterList = chapters.entries.flatMap { chapterEntry ->
val chapterNum = chapterEntry.key val chapterNum = chapterEntry.key
@ -327,13 +358,7 @@ open class Cubari(override val lang: String) : HttpSource() {
date_upload = if (releaseDate != null) { date_upload = if (releaseDate != null) {
releaseDate.jsonPrimitive.double.toLong() * 1000 releaseDate.jsonPrimitive.double.toLong() * 1000
} else { } else {
val currentTimeMillis = System.currentTimeMillis() 0L
if (!seriesPrefs.contains(chapterNum)) {
seriesPrefsEditor.putLong(chapterNum, currentTimeMillis)
}
seriesPrefs.getLong(chapterNum, currentTimeMillis)
} }
name = buildString { name = buildString {
@ -351,8 +376,6 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
} }
seriesPrefsEditor.apply()
return chapterList.sortedByDescending { it.chapter_number } return chapterList.sortedByDescending { it.chapter_number }
} }
@ -375,16 +398,6 @@ open class Cubari(override val lang: String) : HttpSource() {
return MangasPage(mangaList, false) return MangasPage(mangaList, false)
} }
private fun parseSearchList(payload: JsonObject, query: String): MangasPage {
val tempManga = SManga.create().apply {
url = "/read/$query"
}
val mangaList = listOf(parseManga(payload, tempManga))
return MangasPage(mangaList, false)
}
private fun parseManga(jsonObj: JsonObject, mangaReference: SManga? = null): SManga = private fun parseManga(jsonObj: JsonObject, mangaReference: SManga? = null): SManga =
SManga.create().apply { SManga.create().apply {
title = jsonObj["title"]!!.jsonPrimitive.content title = jsonObj["title"]!!.jsonPrimitive.content
@ -413,11 +426,10 @@ open class Cubari(override val lang: String) : HttpSource() {
} }
companion object { companion object {
const val PROXY_PREFIX = "cubari:"
const val AUTHOR_FALLBACK = "Unknown" const val AUTHOR_FALLBACK = "Unknown"
const val ARTIST_FALLBACK = "Unknown" const val ARTIST_FALLBACK = "Unknown"
const val DESCRIPTION_FALLBACK = "No description." const val DESCRIPTION_FALLBACK = "No description."
const val SEARCH_FALLBACK_MSG = "Unable to parse. Is your query in the format of $PROXY_PREFIX<source>/<slug>?" const val SEARCH_FALLBACK_MSG = "Please enter a valid Cubari URL"
enum class SortType { enum class SortType {
PINNED, PINNED,

View File

@ -11,59 +11,20 @@ class CubariUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val host = intent?.data?.host
val pathSegments = intent?.data?.pathSegments
if (host != null && pathSegments != null) { val mainIntent = Intent().apply {
val query = with(host) { action = "eu.kanade.tachiyomi.SEARCH"
when { putExtra("query", intent.data.toString())
equals("m.imgur.com") || equals("imgur.com") -> fromSource("imgur", pathSegments) putExtra("filter", packageName)
equals("m.reddit.com") || equals("reddit.com") || equals("www.reddit.com") -> fromSource("reddit", pathSegments) }
equals("imgchest.com") -> fromSource("imgchest", pathSegments)
equals("catbox.moe") || equals("www.catbox.moe") -> fromSource("catbox", pathSegments)
else -> fromCubari(pathSegments)
}
}
if (query == null) { try {
Log.e("CubariUrlActivity", "Unable to parse URI from intent $intent") startActivity(mainIntent)
finish() } catch (e: ActivityNotFoundException) {
exitProcess(1) Log.e("CubariUrlActivity", "Unable to find activity", e)
}
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", query)
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("CubariUrlActivity", e.toString())
}
} }
finish() finish()
exitProcess(0) exitProcess(0)
} }
private fun fromSource(source: String, pathSegments: List<String>): String? {
if (pathSegments.size >= 2) {
val id = pathSegments[1]
return "${Cubari.PROXY_PREFIX}$source/$id"
}
return null
}
private fun fromCubari(pathSegments: MutableList<String>): String? {
return if (pathSegments.size >= 3) {
val source = pathSegments[1]
val slug = pathSegments[2]
"${Cubari.PROXY_PREFIX}$source/$slug"
} else {
null
}
}
} }