diff --git a/src/all/simplycosplay/AndroidManifest.xml b/src/all/simplycosplay/AndroidManifest.xml new file mode 100644 index 000000000..38f100add --- /dev/null +++ b/src/all/simplycosplay/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/all/simplycosplay/build.gradle b/src/all/simplycosplay/build.gradle new file mode 100644 index 000000000..c99a818f1 --- /dev/null +++ b/src/all/simplycosplay/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Simply Cosplay' + pkgNameSuffix = 'all.simplycosplay' + extClass = '.SimplyCosplay' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" \ No newline at end of file diff --git a/src/all/simplycosplay/res/mipmap-hdpi/ic_launcher.png b/src/all/simplycosplay/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..fa27adc26 Binary files /dev/null and b/src/all/simplycosplay/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/simplycosplay/res/mipmap-mdpi/ic_launcher.png b/src/all/simplycosplay/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..3935d6458 Binary files /dev/null and b/src/all/simplycosplay/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/simplycosplay/res/mipmap-xhdpi/ic_launcher.png b/src/all/simplycosplay/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..23f5ee5a8 Binary files /dev/null and b/src/all/simplycosplay/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/simplycosplay/res/mipmap-xxhdpi/ic_launcher.png b/src/all/simplycosplay/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..88bbe4b20 Binary files /dev/null and b/src/all/simplycosplay/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/simplycosplay/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/simplycosplay/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..bc9f7fdff Binary files /dev/null and b/src/all/simplycosplay/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/simplycosplay/res/web_hi_res_512.png b/src/all/simplycosplay/res/web_hi_res_512.png new file mode 100644 index 000000000..2614f6dcd Binary files /dev/null and b/src/all/simplycosplay/res/web_hi_res_512.png differ diff --git a/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplay.kt b/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplay.kt new file mode 100644 index 000000000..9736f2861 --- /dev/null +++ b/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplay.kt @@ -0,0 +1,382 @@ +package eu.kanade.tachiyomi.extension.all.simplycosplay + +import android.app.Application +import android.content.SharedPreferences +import android.util.Log +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.Filter +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.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +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.io.IOException +import java.text.SimpleDateFormat +import java.util.Locale + +class SimplyCosplay : HttpSource(), ConfigurableSource { + + override val name = "Simply Cosplay" + + override val lang = "all" + + override val baseUrl = "https://www.simply-cosplay.com" + + private val apiUrl = "https://api.simply-porn.com/v2".toHttpUrl() + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .addInterceptor(::tokenIntercept) + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", baseUrl) + + private val json: Json by injectLazy() + + private val preference by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private fun tokenIntercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (request.url.host != apiUrl.host) { + return chain.proceed(request) + } + + val url = request.url.newBuilder() + .setQueryParameter("token", preference.getToken()) + .build() + + val response = chain.proceed( + request.newBuilder() + .url(url) + .build(), + ) + + if (response.isSuccessful.not() && response.code == 403) { + response.close() + + val newToken = fetchNewToken() + + preference.putToken(newToken) + + val newUrl = request.url.newBuilder() + .setQueryParameter("token", newToken) + .build() + + return chain.proceed( + request.newBuilder() + .url(newUrl) + .build(), + ) + } + + return response + } + + private fun fetchNewToken(): String { + val document = client.newCall(GET(baseUrl, headers)).execute().asJsoup() + + val scriptUrl = document.selectFirst("script[src*=main]") + ?.attr("abs:src") + ?: throw IOException(TOKEN_EXCEPTION) + + val scriptContent = client.newCall(GET(scriptUrl, headers)).execute() + .use { it.body.string() } + .replace("\'", "\"") + + return TokenRegex.find(scriptContent)?.groupValues?.get(1) + ?: throw IOException(TOKEN_EXCEPTION) + } + + private fun browseUrlBuilder(endPoint: String, sort: String, page: Int): HttpUrl.Builder { + return apiUrl.newBuilder().apply { + addPathSegment(endPoint) + addQueryParameter("sort", sort) + addQueryParameter("limit", limit.toString()) + addQueryParameter("page", page.toString()) + } + } + + override fun popularMangaRequest(page: Int): Request { + val url = browseUrlBuilder(preference.getDefaultBrowse(), "hot", page) + + return GET(url.build(), headers) + } + + override fun popularMangaParse(response: Response): MangasPage { + runCatching { fetchTags() } + + val result = response.parseAs() + + val entries = result.data.map(BrowseItem::toSManga) + val hasNextPage = result.data.size >= limit + + return MangasPage(entries, hasNextPage) + } + + override fun latestUpdatesRequest(page: Int): Request { + val url = browseUrlBuilder(preference.getDefaultBrowse(), "new", page) + + return GET(url.build(), headers) + } + + override fun latestUpdatesParse(response: Response) = popularMangaParse(response) + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + return if (query.startsWith(SEARCH_PREFIX)) { + val url = query.substringAfter(SEARCH_PREFIX) + val manga = SManga.create().apply { this.url = url } + fetchMangaDetails(manga).map { + MangasPage(listOf(it), false) + } + } else { + super.fetchSearchManga(page, query, filters) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val sort = filters.filterIsInstance().firstOrNull()?.getSort() ?: "new" + + val url = browseUrlBuilder("search", sort, page).apply { + if (query.isNotEmpty()) { + addQueryParameter("query", query) + } + filters.map { filter -> + when (filter) { + is TagFilter -> { + filter.getSelected().forEachIndexed { index, tag -> + addQueryParameter( + "filter[tag_names][$index]", + tag.name.replace(" ", "+"), + ) + } + } + is TypeFilter -> { + filter.getValue().let { + if (it.isNotEmpty()) { + addQueryParameter("filter[type][0]", it) + } + } + } + else -> { } + } + } + } + + return GET(url.build(), headers) + } + + override fun searchMangaParse(response: Response) = popularMangaParse(response) + + private var tagList: List = emptyList() + private var tagsFetchAttempt = 0 + private var tagsFetchFailed = false + + private fun fetchTags() { + if (tagsFetchAttempt < 3 && (tagList.isEmpty() || tagsFetchFailed)) { + val tags = runCatching { + client.newCall(tagsRequest()) + .execute().use(::tagsParse) + } + + tagsFetchFailed = tags.isFailure + tagList = tags.getOrElse { + Log.e("SimplyHentaiTags", it.stackTraceToString()) + emptyList() + } + tagsFetchAttempt++ + } + } + + private fun tagsRequest(): Request { + val url = apiUrl.newBuilder() + .addPathSegment("search") + .build() + + return GET(url, headers) + } + + private fun tagsParse(response: Response): List { + val result = response.parseAs() + + return result.aggs.tag_names.buckets.map { + it.key.trim() + } + } + + class Tag(name: String) : Filter.CheckBox(name) + + class TagFilter(title: String, tags: List) : + Filter.Group(title, tags.map(::Tag)) { + + fun getSelected() = state.filter { it.state } + } + + class TypeFilter(title: String, private val types: List) : + Filter.Select(title, types.toTypedArray()) { + + fun getValue() = types[state].lowercase() + } + + class SortFilter(title: String, private val sorts: List) : + Filter.Select(title, sorts.toTypedArray()) { + + fun getSort() = sorts[state].lowercase() + } + + override fun getFilterList(): FilterList { + val filters: MutableList> = mutableListOf( + SortFilter("Sort", listOf("New", "Hot")), + TypeFilter("Type", listOf("", "Image", "Gallery")), + ) + + if (tagList.isNotEmpty()) { + filters += TagFilter("Tags", tagList) + } else { + filters += listOf( + Filter.Separator(), + Filter.Header("Press 'Reset' to attempt to show tags"), + ) + } + + return FilterList(filters) + } + + private fun mangaUrlBuilder(dbUrl: String): HttpUrl.Builder { + val pathSegments = dbUrl.split("/") + val type = pathSegments[1] + val slug = pathSegments[3] + + return apiUrl.newBuilder().apply { + addPathSegment(type) + addPathSegments(slug) + } + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val url = mangaUrlBuilder(manga.url) + + return GET(url.build(), headers) + } + + override fun mangaDetailsParse(response: Response): SManga { + val result = response.parseAs() + + return result.data.toSManga() + } + + override fun getMangaUrl(manga: SManga) = baseUrl + manga.url + + override fun fetchChapterList(manga: SManga): Observable> { + return Observable.just( + listOf( + SChapter.create().apply { + url = manga.url + name = manga.url.split("/")[1].replaceFirstChar { + if (it.isLowerCase()) { + it.titlecase( + Locale.ROOT, + ) + } else { + it.toString() + } + } + date_upload = manga.description?.substringAfterLast("Date: ").parseDate() + }, + ), + ) + } + + override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url + + override fun pageListRequest(chapter: SChapter): Request { + val url = mangaUrlBuilder(chapter.url) + + return GET(url.build(), headers) + } + + override fun pageListParse(response: Response): List { + val result = response.parseAs() + + return result.data.images?.mapIndexedNotNull { index, image -> + if (image.urls.url.isNullOrEmpty()) { + null + } else { + Page(index, "", image.urls.url) + } + } + ?: Page(1, "", result.data.preview.urls.url).let(::listOf) + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + ListPreference(screen.context).apply { + key = BROWSE_TYPE_PREF_KEY + title = BROWSE_TYPE_TITLE + entries = arrayOf("Gallery", "Image") + entryValues = arrayOf("gallery", "image") + summary = "%s" + setDefaultValue("gallery") + }.also(screen::addPreference) + } + + private fun SharedPreferences.getDefaultBrowse() = + getString(BROWSE_TYPE_PREF_KEY, "gallery")!! + + private fun SharedPreferences.getToken() = + getString(DEFAULT_TOKEN_PREF, DEFAULT_FALLBACK_TOKEN) ?: DEFAULT_FALLBACK_TOKEN + + private fun SharedPreferences.putToken(token: String) = + edit().putString(DEFAULT_TOKEN_PREF, token).commit() + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } + + private fun String?.parseDate(): Long { + return runCatching { dateFormat.parse(this!!)!!.time } + .getOrDefault(0L) + } + + companion object { + private const val limit = 20 + const val SEARCH_PREFIX = "url:" + + private const val DEFAULT_TOKEN_PREF = "default_token_pref" + private const val DEFAULT_FALLBACK_TOKEN = "01730876" + private const val TOKEN_EXCEPTION = "Unable to fetch new Token" + private val TokenRegex = Regex("""token\s*:\s*"([^\"]+)""") + + private val dateFormat by lazy { SimpleDateFormat("yyy-MM-dd'T'HH:mm:ss.SSS", Locale.ENGLISH) } + + private const val BROWSE_TYPE_PREF_KEY = "default_browse_type_key" + private const val BROWSE_TYPE_TITLE = "Default Browse List" + } + + override fun chapterListParse(response: Response) = + throw UnsupportedOperationException("Not implemented") + + override fun imageUrlParse(response: Response) = + throw UnsupportedOperationException("Not implemented") +} diff --git a/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplayDto.kt b/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplayDto.kt new file mode 100644 index 000000000..9cbe15cdf --- /dev/null +++ b/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplayDto.kt @@ -0,0 +1,116 @@ +package eu.kanade.tachiyomi.extension.all.simplycosplay + +import eu.kanade.tachiyomi.source.model.SManga +import eu.kanade.tachiyomi.source.model.UpdateStrategy +import kotlinx.serialization.Serializable +import java.util.Locale + +typealias browseResponse = Data> + +typealias detailsResponse = Data + +typealias pageResponse = Data + +@Serializable +data class Data(val data: T) + +@Serializable +data class BrowseItem( + val title: String? = null, + val slug: String, + val type: String, + val preview: Images, +) { + fun toSManga() = SManga.create().apply { + title = this@BrowseItem.title ?: "" + url = "/${type.lowercase().trim()}/new/$slug" + thumbnail_url = preview.urls.thumb.url + description = preview.publish_date?.let { "Date: $it" } + } +} + +@Serializable +data class TagsResponse( + val aggs: Agg, +) + +@Serializable +data class Agg( + val tag_names: TagNames, +) + +@Serializable +data class TagNames( + val buckets: List, +) + +@Serializable +data class GenreItem( + val key: String, +) + +@Serializable +data class DetailsResponse( + val title: String? = null, + val slug: String, + val type: String, + val preview: Images, + val tags: List? = emptyList(), + val image_count: Int? = null, +) { + fun toSManga() = SManga.create().apply { + title = this@DetailsResponse.title ?: "" + url = "/${type.lowercase().trim()}/new/$slug" + thumbnail_url = preview.urls.thumb.url + genre = tags?.mapNotNull { it -> + it.name?.trim()?.split(" ")?.let { genre -> + genre.map { + it.replaceFirstChar { char -> + if (char.isLowerCase()) { + char.titlecase( + Locale.ROOT, + ) + } else { + char.toString() + } + } + } + }?.joinToString(" ") + }?.joinToString() + description = buildString { + append("Type: $type\n") + image_count?.let { append("Images: $it\n") } + preview.publish_date?.let { append("Date: $it\n") } + } + update_strategy = UpdateStrategy.ONLY_FETCH_ONCE + status = SManga.COMPLETED + } +} + +@Serializable +data class PageResponse( + val images: List? = null, + val preview: Images, +) + +@Serializable +data class Images( + val publish_date: String? = null, + val urls: Urls, +) + +@Serializable +data class Urls( + val url: String? = null, + val thumb: Url, +) + +@Serializable +data class Url( + val url: String? = null, +) + +@Serializable +data class Tag( + val name: String? = null, +) diff --git a/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplayUrlActivity.kt b/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplayUrlActivity.kt new file mode 100644 index 000000000..30bdb1ea8 --- /dev/null +++ b/src/all/simplycosplay/src/eu/kanade/tachiyomi/extension/all/simplycosplay/SimplyCosplayUrlActivity.kt @@ -0,0 +1,33 @@ +package eu.kanade.tachiyomi.extension.all.simplycosplay + +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 SimplyCosplayUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size >= 3) { + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${SimplyCosplay.SEARCH_PREFIX}/${pathSegments[0]}/new/${pathSegments[2]}") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("SimplyCosplayUrlActivit", e.toString()) + } + } else { + Log.e("SimplyCosplayUrlActivit", "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } +}