diff --git a/.run/KemonoGenerator.run.xml b/.run/KemonoGenerator.run.xml new file mode 100644 index 000000000..2ac0c58da --- /dev/null +++ b/.run/KemonoGenerator.run.xml @@ -0,0 +1,17 @@ +<component name="ProjectRunConfigurationManager"> + <configuration default="false" name="KemonoGenerator" type="JetRunConfigurationType" nameIsGenerated="true"> + <module name="tachiyomi-extensions.multisrc" /> + <option name="VM_PARAMETERS" value="" /> + <option name="PROGRAM_PARAMETERS" value="" /> + <option name="ALTERNATIVE_JRE_PATH_ENABLED" value="false" /> + <option name="ALTERNATIVE_JRE_PATH" /> + <option name="PASS_PARENT_ENVS" value="true" /> + <option name="MAIN_CLASS_NAME" value="eu.kanade.tachiyomi.multisrc.kemono.KemonoGenerator" /> + <option name="WORKING_DIRECTORY" value="" /> + <method v="2"> + <option name="Make" enabled="true" /> + <option name="Gradle.BeforeRunTask" enabled="true" tasks="ktFormat" externalProjectPath="$PROJECT_DIR$/multisrc" vmOptions="" scriptParameters="" /> + <option name="Gradle.BeforeRunTask" enabled="true" tasks="ktLint" externalProjectPath="$PROJECT_DIR$/multisrc" vmOptions="" scriptParameters="" /> + </method> + </configuration> +</component> \ No newline at end of file diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..fdf273a9b Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-hdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..bf37a1b7a Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-mdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..f9b11f5f0 Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..04edc56b2 Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/coomer/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/kemono/coomer/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..61b49c814 Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/coomer/res/web_hi_res_512.png b/multisrc/overrides/kemono/coomer/res/web_hi_res_512.png new file mode 100644 index 000000000..0f401f9eb Binary files /dev/null and b/multisrc/overrides/kemono/coomer/res/web_hi_res_512.png differ diff --git a/multisrc/overrides/kemono/default/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..404a9888f Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-hdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/default/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..b7e10b9f9 Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-mdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/default/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..fabf9b671 Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/default/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..aeff4ae0e Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/default/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/kemono/default/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..1597c0783 Binary files /dev/null and b/multisrc/overrides/kemono/default/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/kemono/default/res/web_hi_res_512.png b/multisrc/overrides/kemono/default/res/web_hi_res_512.png new file mode 100644 index 000000000..e5cbe8a72 Binary files /dev/null and b/multisrc/overrides/kemono/default/res/web_hi_res_512.png differ diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/Kemono.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/Kemono.kt new file mode 100644 index 000000000..5b03d40b7 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/Kemono.kt @@ -0,0 +1,155 @@ +package eu.kanade.tachiyomi.multisrc.kemono + +import android.app.Application +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +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.online.HttpSource +import eu.kanade.tachiyomi.util.asJsoup +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Element +import org.jsoup.select.Evaluator +import rx.Observable +import rx.Single +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.util.TimeZone + +open class Kemono( + override val name: String, + override val baseUrl: String, + override val lang: String = "all", +) : HttpSource(), ConfigurableSource { + override val supportsLatest = true + + override val client = network.client.newBuilder().rateLimit(2).build() + + private val json: Json by injectLazy() + + private val preferences by lazy { + Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000) + } + + override fun popularMangaRequest(page: Int): Request = + GET("$baseUrl/artists?o=${PAGE_SIZE * (page - 1)}", headers) + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + val cardList = document.selectFirst(Evaluator.Class("card-list")) + val creators = cardList.select(Evaluator.Tag("article")).map { + val children = it.children() + val avatar = children[0].selectFirst(Evaluator.Tag("img")).attr("src") + val link = children[1].child(0) + val service = children[2].ownText() + SManga.create().apply { + url = link.attr("href") + title = link.ownText() + author = service + thumbnail_url = baseUrl + avatar + description = PROMPT + initialized = true + } + }.filterUnsupported() + return MangasPage(creators, document.hasNextPage()) + } + + override fun latestUpdatesRequest(page: Int): Request = + GET("$baseUrl/artists/updated?o=${PAGE_SIZE * (page - 1)}", headers) + + override fun latestUpdatesParse(response: Response) = popularMangaParse(response) + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> = Single.create<MangasPage> { subscriber -> + val baseUrl = this.baseUrl + val response = client.newCall(GET("$baseUrl/api/creators", headers)).execute() + val result = response.parseAs<List<KemonoCreatorDto>>() + .filter { it.name.contains(query, ignoreCase = true) } + .sortedByDescending { it.updatedDate } + .map { it.toSManga(baseUrl) } + .filterUnsupported() + subscriber.onSuccess(MangasPage(result, false)) + }.toObservable() + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException("Not used.") + override fun searchMangaParse(response: Response) = throw UnsupportedOperationException("Not used.") + + override fun fetchMangaDetails(manga: SManga): Observable<SManga> = Observable.just(manga) + + override fun mangaDetailsParse(response: Response) = throw UnsupportedOperationException("Not used.") + + override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> = Single.create<List<SChapter>> { + KemonoPostDto.dateFormat.timeZone = when (manga.author) { + "Pixiv Fanbox", "Fantia" -> TimeZone.getTimeZone("GMT+09:00") + else -> TimeZone.getTimeZone("GMT") + } + val maxPosts = preferences.getString(POST_PAGES_PREF, POST_PAGES_DEFAULT)!! + .toInt().coerceAtMost(POST_PAGES_MAX) * POST_PAGE_SIZE + var offset = 0 + var hasNextPage = true + val result = ArrayList<SChapter>() + while (offset < maxPosts && hasNextPage) { + val request = GET("$baseUrl/api${manga.url}?limit=$POST_PAGE_SIZE&o=$offset", headers) + val page: List<KemonoPostDto> = client.newCall(request).execute().parseAs() + page.forEach { post -> if (post.images.isNotEmpty()) result.add(post.toSChapter()) } + offset += POST_PAGE_SIZE + hasNextPage = page.size == POST_PAGE_SIZE + } + it.onSuccess(result) + }.toObservable() + + override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not used.") + + override fun pageListRequest(chapter: SChapter): Request = + GET("$baseUrl/api${chapter.url}", headers) + + override fun pageListParse(response: Response): List<Page> { + val post: List<KemonoPostDto> = response.parseAs() + return post[0].images.mapIndexed { i, path -> Page(i, imageUrl = baseUrl + path) } + } + + override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.") + + private inline fun <reified T> Response.parseAs(): T = use { + json.decodeFromStream(it.body!!.byteStream()) + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + ListPreference(screen.context).apply { + key = POST_PAGES_PREF + title = "Maximum posts to load" + summary = "Loading more posts costs more time and network traffic.\nCurrently: %s" + entryValues = (1..POST_PAGES_MAX).map { it.toString() }.toTypedArray() + entries = (1..POST_PAGES_MAX).map { + if (it == 1) "1 page ($POST_PAGE_SIZE posts)" else "$it pages (${it * POST_PAGE_SIZE} posts)" + }.toTypedArray() + setDefaultValue(POST_PAGES_DEFAULT) + }.let { screen.addPreference(it) } + } + + companion object { + private const val PAGE_SIZE = 25 + const val PROMPT = "You can change how many posts to load in the extension preferences." + + private const val POST_PAGE_SIZE = 50 + private const val POST_PAGES_PREF = "POST_PAGES" + private const val POST_PAGES_DEFAULT = "1" + private const val POST_PAGES_MAX = 50 + + private fun Element.hasNextPage(): Boolean { + val pagination = selectFirst(Evaluator.Class("paginator")) + return pagination.selectFirst("a[title=Next page]") != null + } + + private fun List<SManga>.filterUnsupported() = filterNot { it.author == "Discord" } + } +} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoDto.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoDto.kt new file mode 100644 index 000000000..9f50f81c7 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoDto.kt @@ -0,0 +1,82 @@ +package eu.kanade.tachiyomi.multisrc.kemono + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class KemonoCreatorDto( + private val id: String, + val name: String, + private val service: String, + private val updated: String, +) { + val updatedDate get() = dateFormat.parse(updated)?.time ?: 0 + + fun toSManga(baseUrl: String) = SManga.create().apply { + url = "/$service/user/$id" // should be /server/ for Discord but will be filtered anyway + title = name + author = service.serviceName() + thumbnail_url = "$baseUrl/icons/$service/$id" + description = Kemono.PROMPT + initialized = true + } + + companion object { + private val dateFormat by lazy { getApiDateFormat() } + + fun String.serviceName() = when (this) { + "fanbox" -> "Pixiv Fanbox" + "subscribestar" -> "SubscribeStar" + "dlsite" -> "DLsite" + "onlyfans" -> "OnlyFans" + else -> replaceFirstChar { it.uppercase() } + } + } +} + +@Serializable +class KemonoPostDto( + private val id: String, + private val service: String, + private val user: String, + private val title: String, + private val added: String, + private val published: String?, + private val edited: String?, + private val file: KemonoFileDto, + private val attachments: List<KemonoAttachmentDto>, +) { + val images: List<String> + get() = buildList(attachments.size + 1) { + file.path?.let { add(it) } + attachments.mapTo(this) { it.path } + }.filter { + when (it.substringAfterLast('.').lowercase()) { + "png", "jpg", "gif", "jpeg", "webp" -> true + else -> false + } + }.distinct() + + fun toSChapter() = SChapter.create().apply { + url = "/$service/user/$user/post/$id" + name = title + date_upload = dateFormat.parse(edited ?: published ?: added)?.time ?: 0 + chapter_number = -2f + } + + companion object { + val dateFormat by lazy { getApiDateFormat() } + } +} + +@Serializable +class KemonoFileDto(val path: String? = null) + +@Serializable +class KemonoAttachmentDto(val path: String) + +private fun getApiDateFormat() = + SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.ENGLISH) diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoGenerator.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoGenerator.kt new file mode 100644 index 000000000..977e44944 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/kemono/KemonoGenerator.kt @@ -0,0 +1,21 @@ +package eu.kanade.tachiyomi.multisrc.kemono + +import generator.ThemeSourceData.SingleLang +import generator.ThemeSourceGenerator + +class KemonoGenerator : ThemeSourceGenerator { + override val themeClass = "Kemono" + override val themePkg = "kemono" + override val baseVersionCode = 1 + override val sources = listOf( + SingleLang("Kemono", "https://kemono.party", "all", isNsfw = true, className = "KemonoParty", pkgName = "kemono"), + SingleLang("Coomer", "https://coomer.party", "all", isNsfw = true) + ) + + companion object { + @JvmStatic + fun main(args: Array<String>) { + KemonoGenerator().createAll() + } + } +}