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) + } +}