diff --git a/src/en/koharu/AndroidManifest.xml b/src/en/koharu/AndroidManifest.xml
new file mode 100644
index 000000000..5f565204f
--- /dev/null
+++ b/src/en/koharu/AndroidManifest.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/koharu/build.gradle b/src/en/koharu/build.gradle
new file mode 100644
index 000000000..edf2bff13
--- /dev/null
+++ b/src/en/koharu/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'Koharu'
+ extClass = '.Koharu'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/koharu/res/mipmap-hdpi/ic_launcher.png b/src/en/koharu/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..03649687f
Binary files /dev/null and b/src/en/koharu/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/koharu/res/mipmap-mdpi/ic_launcher.png b/src/en/koharu/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..5728c35f8
Binary files /dev/null and b/src/en/koharu/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/koharu/res/mipmap-xhdpi/ic_launcher.png b/src/en/koharu/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..4237ae4c4
Binary files /dev/null and b/src/en/koharu/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/koharu/res/mipmap-xxhdpi/ic_launcher.png b/src/en/koharu/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..fd67a6cc3
Binary files /dev/null and b/src/en/koharu/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/koharu/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/koharu/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..a22749b24
Binary files /dev/null and b/src/en/koharu/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/Koharu.kt b/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/Koharu.kt
new file mode 100644
index 000000000..1698cd469
--- /dev/null
+++ b/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/Koharu.kt
@@ -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().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()
+ 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()
+
+ 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 {
+ 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()
+
+ 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().toSManga()
+ }
+
+ private val dateReformat = SimpleDateFormat("EEEE, d MMM yyyy HH:mm (z)", Locale.ENGLISH)
+ private fun MangaEntry.toSManga() = SManga.create().apply {
+ val artists = mutableListOf()
+ val circles = mutableListOf()
+ val parodies = mutableListOf()
+ val magazines = mutableListOf()
+ val characters = mutableListOf()
+ val cosplayers = mutableListOf()
+ val females = mutableListOf()
+ val males = mutableListOf()
+ val mixed = mutableListOf()
+ val other = mutableListOf()
+ val uploaders = mutableListOf()
+ val tags = mutableListOf()
+ 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 Collection.emptyToNull(): Collection? {
+ 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 {
+ val manga = response.parseAs()
+ 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 {
+ val mangaEntry = response.parseAs()
+ 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 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"
+ }
+}
diff --git a/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuDto.kt b/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuDto.kt
new file mode 100644
index 000000000..b3cd70062
--- /dev/null
+++ b/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuDto.kt
@@ -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 = 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 = emptyList(),
+ val data: Data,
+)
+
+@Serializable
+class Thumbnails(
+ val base: String,
+ val main: Thumbnail,
+ val entries: List,
+)
+
+@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,
+)
+
+@Serializable
+class ImagePath(
+ val path: String,
+)
diff --git a/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuFilters.kt b/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuFilters.kt
new file mode 100644
index 000000000..603de00fc
--- /dev/null
+++ b/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuFilters.kt
@@ -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>, state: Int = 0) :
+ Filter.Select(name, vals.map { it.first }.toTypedArray(), state) {
+ fun getValue() = vals[state].second
+}
+
+internal class CategoryFilter(name: String) :
+ Filter.Group(
+ 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> = listOf(
+ Pair("Title", "1"),
+ Pair("Pages", "2"),
+ Pair("Recently Posted", ""),
+ Pair("Most Viewed", "6"),
+ Pair("Most Favorited", "8"),
+)
diff --git a/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuUrlActivity.kt b/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuUrlActivity.kt
new file mode 100644
index 000000000..56799263d
--- /dev/null
+++ b/src/en/koharu/src/eu/kanade/tachiyomi/extension/en/koharu/KoharuUrlActivity.kt
@@ -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)
+ }
+}