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:
parent
7257dc89a8
commit
41812dd97b
|
@ -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>
|
|
@ -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 |
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
|
@ -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"),
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue