Add Koharu (#3981)

* Add Koharu.to

* Remove some useless things

* Fixed Filters, and More

- Fixed Filters/Search not working when query is empty
- Changed image resolution default value to match website's
- Use `when` instead of kotlin's reflect
- Added search by "id", and url intent filter?

* Apply Suggestions

- Apply vetleledaal's suggestions
- Fix UrlActivity?

* Apply suggestions

* Apply Suggestions

* Add files via upload
This commit is contained in:
KenjieDec 2024-07-16 12:27:24 +07:00 committed by Draff
parent 7257dc89a8
commit 41812dd97b
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
11 changed files with 496 additions and 0 deletions

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".en.koharu.KoharuUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<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="koharu.to"
android:pathPattern="/g/..*/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,8 @@
ext {
extName = 'Koharu'
extClass = '.Koharu'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

View File

@ -0,0 +1,305 @@
package eu.kanade.tachiyomi.extension.en.koharu
import android.app.Application
import android.content.SharedPreferences
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.interceptor.rateLimit
import eu.kanade.tachiyomi.source.ConfigurableSource
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.UpdateStrategy
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
class Koharu : HttpSource(), ConfigurableSource {
override val name = "Koharu"
override val baseUrl = "https://koharu.to"
private val apiUrl = baseUrl.replace("://", "://api.")
private val apiBooksUrl = "$apiUrl/books"
override val lang = "en"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient.newBuilder()
.rateLimit(1)
.build()
private val json: Json by injectLazy()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private fun quality() = preferences.getString(PREF_IMAGERES, "1280")!!
override fun headersBuilder() = super.headersBuilder()
.add("Referer", "$baseUrl/")
.add("Origin", baseUrl)
private fun getManga(book: Entry) = SManga.create().apply {
setUrlWithoutDomain("${book.id}/${book.public_key}")
title = book.title
thumbnail_url = book.thumbnail.path
}
private fun getImagesByMangaEntry(entry: MangaEntry): ImagesInfo {
val data = entry.data
val dataKey = when (quality()) {
"1600" -> data.`1600` ?: data.`1280` ?: data.`0`
"1280" -> data.`1280` ?: data.`1600` ?: data.`0`
"980" -> data.`980` ?: data.`1280` ?: data.`0`
"780" -> data.`780` ?: data.`980` ?: data.`0`
else -> data.`0`
}
val imagesResponse = client.newCall(POST("$apiBooksUrl/data/${entry.id}/${entry.public_key}/${dataKey.id}/${dataKey.public_key}", headers)).execute()
val images = imagesResponse.parseAs<ImagesInfo>()
return images
}
// Latest
override fun latestUpdatesRequest(page: Int) = GET("$apiBooksUrl?page=$page", headers)
override fun latestUpdatesParse(response: Response) = popularMangaParse(response)
// Popular
override fun popularMangaRequest(page: Int) = GET("$apiBooksUrl?sort=6&page=$page", headers)
override fun popularMangaParse(response: Response): MangasPage {
val data = response.parseAs<Books>()
return MangasPage(data.entries.map(::getManga), data.page * data.limit < data.total)
}
// Search
override fun getFilterList(): FilterList = getFilters()
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return when {
query.startsWith(PREFIX_ID_KEY_SEARCH) -> {
val ipk = query.removePrefix(PREFIX_ID_KEY_SEARCH)
val response = client.newCall(GET("$apiBooksUrl/detail/$ipk", headers)).execute()
Observable.just(searchMangaParse2(response))
}
else -> super.fetchSearchManga(page, query, filters)
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val url = apiBooksUrl.toHttpUrl().newBuilder().apply {
val terms = mutableListOf(query.trim())
filters.forEach { filter ->
when (filter) {
is SortFilter -> addQueryParameter("sort", filter.getValue())
is CategoryFilter -> {
val activeFilter = filter.state.filter { it.state }
if (activeFilter.isNotEmpty()) {
addQueryParameter("cat", activeFilter.sumOf { it.value }.toString())
}
}
is TextFilter -> {
if (filter.state.isNotEmpty()) {
terms += filter.state.split(",").filter(String::isNotBlank).map { tag ->
val trimmed = tag.trim()
buildString {
if (trimmed.startsWith('-')) {
append("-")
}
append(filter.type)
append("!:")
append("\"")
append(trimmed.lowercase().removePrefix("-"))
append("\"")
}
}
}
}
else -> {}
}
}
if (query.isNotEmpty()) terms.add("title:\"$query\"")
if (terms.isNotEmpty()) addQueryParameter("s", terms.joinToString(" "))
addQueryParameter("page", page.toString())
}.build()
return GET(url, headers)
}
override fun searchMangaParse(response: Response) = popularMangaParse(response)
private fun searchMangaParse2(response: Response): MangasPage {
val entry = response.parseAs<MangaEntry>()
return MangasPage(
listOf(
SManga.create().apply {
setUrlWithoutDomain("${entry.id}/${entry.public_key}")
title = entry.title
thumbnail_url = entry.thumbnails.base + entry.thumbnails.main.path
},
),
false,
)
}
// Details
override fun mangaDetailsRequest(manga: SManga): Request {
return GET("$apiBooksUrl/detail/${manga.url}", headers)
}
override fun mangaDetailsParse(response: Response): SManga {
return response.parseAs<MangaEntry>().toSManga()
}
private val dateReformat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
private fun MangaEntry.toSManga() = SManga.create().apply {
val artists = mutableListOf<String>()
val circles = mutableListOf<String>()
val parodies = mutableListOf<String>()
val magazines = mutableListOf<String>()
val characters = mutableListOf<String>()
val cosplayers = mutableListOf<String>()
val females = mutableListOf<String>()
val males = mutableListOf<String>()
val mixed = mutableListOf<String>()
val other = mutableListOf<String>()
val uploaders = mutableListOf<String>()
val tags = mutableListOf<String>()
for (tag in this@toSManga.tags) {
when (tag.namespace) {
1 -> artists.add(tag.name)
2 -> circles.add(tag.name)
3 -> parodies.add(tag.name)
4 -> magazines.add(tag.name)
5 -> characters.add(tag.name)
6 -> cosplayers.add(tag.name)
7 -> uploaders.add(tag.name)
8 -> males.add(tag.name + "")
9 -> females.add(tag.name + "")
10 -> mixed.add(tag.name)
12 -> other.add(tag.name)
else -> tags.add(tag.name)
}
}
author = (circles.emptyToNull() ?: artists).joinToString()
artist = artists.joinToString()
genre = (tags + males + females + mixed).joinToString()
description = buildString {
circles.emptyToNull()?.joinToString()?.let {
append("Circles: ", it, "\n")
}
uploaders.emptyToNull()?.joinToString()?.let {
append("Uploaders: ", it, "\n")
}
magazines.emptyToNull()?.joinToString()?.let {
append("Magazines: ", it, "\n")
}
cosplayers.emptyToNull()?.joinToString()?.let {
append("Cosplayers: ", it, "\n")
}
parodies.emptyToNull()?.joinToString()?.let {
append("Parodies: ", it, "\n")
}
characters.emptyToNull()?.joinToString()?.let {
append("Characters: ", it, "\n")
}
append("Pages: ", thumbnails.entries.size, "\n\n")
try {
append("Added: ", dateReformat.format(((updated_at ?: created_at))), "\n")
} catch (_: Exception) {}
}
status = SManga.COMPLETED
update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
initialized = true
}
private fun <T> Collection<T>.emptyToNull(): Collection<T>? {
return this.ifEmpty { null }
}
override fun getMangaUrl(manga: SManga) = "$baseUrl/g/${manga.url}"
// Chapter
override fun chapterListRequest(manga: SManga): Request {
return GET("$apiBooksUrl/detail/${manga.url}", headers)
}
override fun chapterListParse(response: Response): List<SChapter> {
val manga = response.parseAs<MangaEntry>()
return listOf(
SChapter.create().apply {
name = "Chapter"
url = "${manga.id}/${manga.public_key}"
date_upload = (manga.updated_at ?: manga.created_at)
},
)
}
override fun getChapterUrl(chapter: SChapter) = "$baseUrl/g/${chapter.url}"
// Page List
override fun pageListRequest(chapter: SChapter): Request {
return GET("$apiBooksUrl/detail/${chapter.url}", headers)
}
override fun pageListParse(response: Response): List<Page> {
val mangaEntry = response.parseAs<MangaEntry>()
val imagesInfo = getImagesByMangaEntry(mangaEntry)
return imagesInfo.entries.mapIndexed { index, image ->
Page(index, imageUrl = "${imagesInfo.base}/${image.path}")
}
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
// Settings
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_IMAGERES
title = "Image Resolution"
entries = arrayOf("780x", "980x", "1280x", "1600x", "Original")
entryValues = arrayOf("780", "980", "1280", "1600", "0")
summary = "%s"
setDefaultValue("1280")
}.also(screen::addPreference)
}
private inline fun <reified T> Response.parseAs(): T {
return json.decodeFromString(body.string())
}
companion object {
const val PREFIX_ID_KEY_SEARCH = "id:"
private const val PREF_IMAGERES = "pref_image_quality"
}
}

View File

@ -0,0 +1,75 @@
package eu.kanade.tachiyomi.extension.en.koharu
import kotlinx.serialization.Serializable
@Serializable
class Tag(
var name: String,
var namespace: Int = 0,
)
@Serializable
class Books(
val entries: List<Entry> = emptyList(),
val total: Int = 0,
val limit: Int = 0,
val page: Int,
)
@Serializable
class Entry(
val id: Int,
val public_key: String,
val title: String,
val thumbnail: Thumbnail,
)
@Serializable
class MangaEntry(
val id: Int,
val title: String,
val public_key: String,
val created_at: Long = 0L,
val updated_at: Long?,
val thumbnails: Thumbnails,
val tags: List<Tag> = emptyList(),
val data: Data,
)
@Serializable
class Thumbnails(
val base: String,
val main: Thumbnail,
val entries: List<Thumbnail>,
)
@Serializable
class Thumbnail(
val path: String,
)
@Serializable
class Data(
val `0`: DataKey,
val `780`: DataKey? = null,
val `980`: DataKey? = null,
val `1280`: DataKey? = null,
val `1600`: DataKey? = null,
)
@Serializable
class DataKey(
val id: Int,
val public_key: String,
)
@Serializable
class ImagesInfo(
val base: String,
val entries: List<ImagePath>,
)
@Serializable
class ImagePath(
val path: String,
)

View File

@ -0,0 +1,51 @@
package eu.kanade.tachiyomi.extension.en.koharu
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
fun getFilters(): FilterList {
return FilterList(
SortFilter("Sort by", getSortsList),
CategoryFilter("Category"),
Filter.Separator(),
Filter.Header("Separate tags with commas (,)"),
Filter.Header("Prepend with dash (-) to exclude"),
TextFilter("Artists", "artist"),
TextFilter("Magazines", "magazine"),
TextFilter("Publishers", "publisher"),
TextFilter("Characters", "character"),
TextFilter("Cosplayers", "cosplayer"),
TextFilter("Parodies", "parody"),
TextFilter("Circles", "circle"),
TextFilter("Male Tags", "male"),
TextFilter("Female Tags", "female"),
TextFilter("Tags ( Universal )", "tag"),
Filter.Header("Filter by pages, for example: (>20)"),
TextFilter("Pages", "pages"),
)
}
internal open class TextFilter(name: String, val type: String) : Filter.Text(name)
internal open class SortFilter(name: String, private val vals: List<Pair<String, String>>, state: Int = 0) :
Filter.Select<String>(name, vals.map { it.first }.toTypedArray(), state) {
fun getValue() = vals[state].second
}
internal class CategoryFilter(name: String) :
Filter.Group<CheckBoxFilter>(
name,
listOf(
Pair("Manga", 2),
Pair("Doujinshi", 4),
Pair("Illustration", 8),
).map { CheckBoxFilter(it.first, it.second, true) },
)
internal open class CheckBoxFilter(name: String, val value: Int, state: Boolean) : Filter.CheckBox(name, state)
private val getSortsList: List<Pair<String, String>> = listOf(
Pair("Title", "1"),
Pair("Pages", "2"),
Pair("Recently Posted", ""),
Pair("Most Viewed", "6"),
Pair("Most Favorited", "8"),
)

View File

@ -0,0 +1,34 @@
package eu.kanade.tachiyomi.extension.en.koharu
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class KoharuUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 2) {
val id = "${pathSegments[1]}/${pathSegments[2]}"
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${Koharu.PREFIX_ID_KEY_SEARCH}$id")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("KoharuUrlActivity", "Could not start activity", e)
}
} else {
Log.e("KoharuUrlActivity", "Could not parse URI from intent $intent")
}
finish()
exitProcess(0)
}
}