diff --git a/src/all/netcomics/AndroidManifest.xml b/src/all/netcomics/AndroidManifest.xml new file mode 100644 index 000000000..30deb7f79 --- /dev/null +++ b/src/all/netcomics/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/src/all/netcomics/build.gradle b/src/all/netcomics/build.gradle new file mode 100644 index 000000000..c41859c1d --- /dev/null +++ b/src/all/netcomics/build.gradle @@ -0,0 +1,13 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'NETCOMICS' + pkgNameSuffix = 'all.netcomics' + extClass = '.NetcomicsFactory' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/netcomics/res/mipmap-hdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..ddeba992f Binary files /dev/null and b/src/all/netcomics/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/netcomics/res/mipmap-mdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..4175d4c59 Binary files /dev/null and b/src/all/netcomics/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/netcomics/res/mipmap-xhdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..f0f480027 Binary files /dev/null and b/src/all/netcomics/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/netcomics/res/mipmap-xxhdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..c804a4b73 Binary files /dev/null and b/src/all/netcomics/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/netcomics/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/netcomics/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..47b63387c Binary files /dev/null and b/src/all/netcomics/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/netcomics/res/web_hi_res_512.png b/src/all/netcomics/res/web_hi_res_512.png new file mode 100644 index 000000000..8bacbd77c Binary files /dev/null and b/src/all/netcomics/res/web_hi_res_512.png differ diff --git a/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/GenreFilter.kt b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/GenreFilter.kt new file mode 100644 index 000000000..7bafc68d5 --- /dev/null +++ b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/GenreFilter.kt @@ -0,0 +1,23 @@ +package eu.kanade.tachiyomi.extension.all.netcomics + +import eu.kanade.tachiyomi.source.model.Filter + +internal class GenreFilter( + values: Array = genres +) : Filter.Select("Genre", values) { + override fun toString() = if (state == 0) "" else values[state] + + companion object { + internal val NOTE = Header("NOTE: can't be used with text search!") + + private val genres = arrayOf( + "All", + "BL", + "Action", + "Comedy", + "Romance", + "Thriller", + "Drama", + ) + } +} diff --git a/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/Netcomics.kt b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/Netcomics.kt new file mode 100644 index 000000000..4b5d08734 --- /dev/null +++ b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/Netcomics.kt @@ -0,0 +1,259 @@ +package eu.kanade.tachiyomi.extension.all.netcomics + +import android.app.Application +import android.net.Uri +import androidx.preference.EditTextPreference +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservable +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 kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Response +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.Calendar + +class Netcomics( + override val lang: String, + private val site: String +) : ConfigurableSource, HttpSource() { + override val name = "NETCOMICS" + + override val baseUrl = "https://www.netcomics.com" + + override val supportsLatest = true + + private val json by lazy { Injekt.get() } + + private val preferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000)!! + } + + private val adult by lazy { + if (preferences.getBoolean("18+", false)) "Y" else "N" + } + + private val token by lazy { + preferences.getString("token", "")!! + } + + private val quality by lazy { + preferences.getString("quality", "625")!! + } + + private val did by lazy { + System.currentTimeMillis().toString() + } + + private val apiUri by lazy { Uri.parse(API_URL) } + + private val apiHeaders by lazy { + headers.newBuilder() + .set("Origin", baseUrl) + .set("platform", "android") + .set("adult", adult) + .set("token", token) + .set("site", site) + .set("did", did) + .build() + } + + private val day by lazy { + when (Calendar.getInstance()[Calendar.DAY_OF_WEEK]) { + Calendar.MONDAY -> "1" + Calendar.TUESDAY -> "2" + Calendar.WEDNESDAY -> "3" + Calendar.THURSDAY -> "4" + Calendar.FRIDAY -> "5" + else -> "" + } + } + + // Request the real URL for the webview + override fun mangaDetailsRequest(manga: SManga) = + GET("$baseUrl/$site/comic/${manga.slug}", headers) + + override fun searchMangaParse(response: Response) = + response.data>().ifEmpty { + error("No more pages") + }.map { + SManga.create().apply { + url = it.slug + title = it.toString() + genre = it.genres + author = it.authors + artist = it.artists + description = it.description + thumbnail_url = it.thumbnail + status = when { + it.isCompleted -> SManga.COMPLETED + else -> SManga.ONGOING + } + } + }.run { MangasPage(this, size == 20) } + + override fun chapterListParse(response: Response) = + response.data>().map { + SChapter.create().apply { + url = it.path + name = it.toString() + date_upload = it.timestamp + chapter_number = it.number + } + } + + override fun pageListParse(response: Response) = + response.data().map { + Page(it.seq, "", it.toString()) + } + + override fun fetchLatestUpdates(page: Int) = + apiUri.fetch("title", ::searchMangaParse) { + appendEncodedPath("new") + appendQueryParameter("no", page.toString()) + appendQueryParameter("size", "20") + appendQueryParameter("day", day) + } + + override fun fetchPopularManga(page: Int) = + apiUri.fetch("title", ::searchMangaParse) { + appendEncodedPath("free") + appendQueryParameter("no", page.toString()) + appendQueryParameter("size", "20") + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList) = + apiUri.fetch("title", ::searchMangaParse) { + if (query.isNotBlank()) { + appendEncodedPath("search/text") + appendQueryParameter("text", query) + } else { + appendEncodedPath("genre") + appendQueryParameter("genre", filters.genre) + } + appendQueryParameter("no", page.toString()) + appendQueryParameter("size", "20") + } + + override fun fetchMangaDetails(manga: SManga) = + rx.Observable.just(manga.apply { initialized = true })!! + + override fun fetchChapterList(manga: SManga) = + apiUri.fetch("chapter", ::chapterListParse) { + appendEncodedPath("list") + appendEncodedPath(manga.id) + appendEncodedPath("rent") + } + + override fun fetchPageList(chapter: SChapter) = + apiUri.fetch("chapter", ::pageListParse) { + appendEncodedPath("viewer") + appendEncodedPath(quality) + appendEncodedPath(chapter.url) + } + + override fun getFilterList() = + FilterList(GenreFilter.NOTE, GenreFilter()) + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + SwitchPreferenceCompat(screen.context).apply { + key = "18+" + title = "Show 18+" + summaryOff = "18+ OFF" + summaryOn = "18+ ON" + setDefaultValue(false) + + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putBoolean(key, newValue as Boolean).commit() + } + }.let(screen::addPreference) + + // TODO: grab from the webview somehow + EditTextPreference(screen.context).apply { + key = "token" + title = "API key" + dialogTitle = "localStorage['ncx.user.token']" + + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString(key, newValue as String).commit() + } + }.let(screen::addPreference) + + ListPreference(screen.context).apply { + key = "quality" + title = "Image quality" + summary = "%s" + entries = arrayOf("HD", "Medium") + entryValues = arrayOf("1024", "625") + setDefaultValue("625") + + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString(key, newValue as String).commit() + } + }.let(screen::addPreference) + } + + override fun latestUpdatesRequest(page: Int) = + throw UnsupportedOperationException("Not used") + + override fun popularMangaRequest(page: Int) = + throw UnsupportedOperationException("Not used") + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = + throw UnsupportedOperationException("Not used") + + override fun chapterListRequest(manga: SManga) = + throw UnsupportedOperationException("Not used") + + override fun pageListRequest(chapter: SChapter) = + throw UnsupportedOperationException("Not used") + + override fun latestUpdatesParse(response: Response) = + throw UnsupportedOperationException("Not used") + + override fun popularMangaParse(response: Response) = + throw UnsupportedOperationException("Not used") + + override fun mangaDetailsParse(response: Response) = + throw UnsupportedOperationException("Not used") + + override fun imageUrlParse(response: Response) = + throw UnsupportedOperationException("Not used") + + private inline val SManga.slug: String + get() = url.substringBefore('|') + + private inline val SManga.id: String + get() = url.substringAfter('|') + + private inline val FilterList.genre: String + get() = find { it is GenreFilter }?.toString() ?: "" + + private inline fun Response.data() = + json.decodeFromJsonElement( + json.parseToJsonElement(body!!.string()).run { + jsonObject["data"] ?: throw Error( + jsonObject["message"]!!.jsonPrimitive.content + ) + } + ) + + private inline fun Uri.fetch( + path: String, + noinline parse: (Response) -> R, + block: Uri.Builder.() -> Uri.Builder + ) = buildUpon().appendEncodedPath(path).let(block).toString().run { + client.newCall(GET(this, apiHeaders)).asObservable().map(parse)!! + } +} diff --git a/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsAPI.kt b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsAPI.kt new file mode 100644 index 000000000..3f8b1ff85 --- /dev/null +++ b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsAPI.kt @@ -0,0 +1,101 @@ +package eu.kanade.tachiyomi.extension.all.netcomics + +import kotlinx.serialization.Serializable +import org.jsoup.Jsoup +import java.text.SimpleDateFormat +import java.util.Locale + +internal const val API_URL = "https://beta-api.netcomics.com/api/v1" + +private const val CDN_URL = + "https://cdn.netcomics.com/img/fill/324/0/sm/0/plain/s3://" + +private val isoDate by lazy { + SimpleDateFormat("yyyy-MM-d'T'HH:mm:ss.SSS'Z'", Locale.ROOT) +} + +@Serializable +data class Title( + private val title_id: Int, + private val site: String, + private val title_name: String, + private val title_slug: String, + private val story: String, + private val genre: String, + private val age_grade: String, + private val is_end: String, + private val v_cover_img: String, + private val author_story_arr: List, + private val author_picture_arr: List, + private val author_origin_arr: List +) { + val slug: String + get() = "$title_slug|$title_id" + + val description: String? + get() = Jsoup.parse(story)?.text() + + val thumbnail: String + get() = CDN_URL + v_cover_img + + val genres: String + get() = "$genre, $age_grade+" + + val authors: String + get() = (author_story_arr + author_origin_arr).names + + val artists: String + get() = author_picture_arr.names + + val isCompleted: Boolean + get() = is_end == "Y" + + override fun toString() = title_name + + private inline val List.names: String + get() = joinToString { if (site == "KR") it.text else it.text_en } +} + +@Serializable +data class Author(val text: String, val text_en: String) + +@Serializable +data class Chapter( + private val chapter_id: Int, + private val chapter_no: Int, + private val chapter_name: String, + private val created_at: String, + private val title_id: Int, + private val is_free: String, + private val is_order: String? = null +) { + val path: String + get() = "$title_id/$chapter_id" + + val number: Float + get() = chapter_no.toFloat() + + val timestamp: Long + get() = isoDate.parse(created_at)?.time ?: 0L + + private inline val isLocked: Boolean + get() = is_free == "N" && is_order != "Y" + + override fun toString() = buildString { + if (chapter_name.isEmpty()) { + append("Ch.") + append(chapter_no) + } else { + append(chapter_name) + } + if (isLocked) append(" \uD83D\uDD12") + } +} + +@Serializable +data class PageList(private val images: List) : List by images + +@Serializable +data class Image(val seq: Int, private val image_url: String) { + override fun toString() = image_url +} diff --git a/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsFactory.kt b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsFactory.kt new file mode 100644 index 000000000..d0163e3df --- /dev/null +++ b/src/all/netcomics/src/eu/kanade/tachiyomi/extension/all/netcomics/NetcomicsFactory.kt @@ -0,0 +1,18 @@ +package eu.kanade.tachiyomi.extension.all.netcomics + +import eu.kanade.tachiyomi.source.SourceFactory + +class NetcomicsFactory : SourceFactory { + override fun createSources() = listOf( + Netcomics("en", "EN"), + Netcomics("ja", "JA"), + Netcomics("zh", "CN"), + Netcomics("ko", "KO"), + Netcomics("es", "ES"), + Netcomics("fr", "FR"), + Netcomics("de", "DE"), + Netcomics("id", "ID"), + Netcomics("vi", "VI"), + Netcomics("th", "TH"), + ) +}