diff --git a/src/en/arcrelight/build.gradle b/src/en/arcrelight/build.gradle new file mode 100644 index 000000000..8f14bbad3 --- /dev/null +++ b/src/en/arcrelight/build.gradle @@ -0,0 +1,15 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +ext { + appName = 'Tachiyomi: Arc-Relight' + pkgNameSuffix = 'en.arcrelight' + extClass = '.ArcRelight' + extVersionCode = 1 + extVersionSuffix = 1 + libVersion = '1.2' +} + +apply from: "$rootDir/common.gradle" + + diff --git a/src/en/arcrelight/res/mipmap-hdpi/ic_launcher.png b/src/en/arcrelight/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..c754bc9ec Binary files /dev/null and b/src/en/arcrelight/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/arcrelight/res/mipmap-mdpi/ic_launcher.png b/src/en/arcrelight/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..356dff73f Binary files /dev/null and b/src/en/arcrelight/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/arcrelight/res/mipmap-xhdpi/ic_launcher.png b/src/en/arcrelight/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..1a29ebb8f Binary files /dev/null and b/src/en/arcrelight/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/arcrelight/res/mipmap-xxhdpi/ic_launcher.png b/src/en/arcrelight/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d38ac11d3 Binary files /dev/null and b/src/en/arcrelight/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/arcrelight/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/arcrelight/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..e8ae26054 Binary files /dev/null and b/src/en/arcrelight/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/arcrelight/res/web_hi_res_512.png b/src/en/arcrelight/res/web_hi_res_512.png new file mode 100644 index 000000000..f08eadcf3 Binary files /dev/null and b/src/en/arcrelight/res/web_hi_res_512.png differ diff --git a/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ARFilters.kt b/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ARFilters.kt new file mode 100644 index 000000000..0019a39a8 --- /dev/null +++ b/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ARFilters.kt @@ -0,0 +1,64 @@ +package eu.kanade.tachiyomi.extension.en.arcrelight + +import eu.kanade.tachiyomi.source.model.Filter + +/** Array containing the possible statuses of a manga */ +private val STATUSES = arrayOf("Any", "Completed", "Ongoing") + +/** List containing the possible categories of a manga */ +private val CATEGORIES = listOf( + Category("4-Koma"), + Category("Chaos;Head"), + Category("Comedy"), + Category("Drama"), + Category("Mystery"), + Category("Psychological"), + Category("Robotics;Notes"), + Category("Romance"), + Category("Sci-Fi"), + Category("Seinen"), + Category("Shounen"), + Category("Steins;Gate"), + Category("Supernatural"), + Category("Tragedy") +) + +/** + * Filter representing the status of a manga. + * + * @constructor Creates a [Filter.Select] object with [STATUSES]. + */ +class Status : Filter.Select("Status", STATUSES) { + /** Returns the [state] as a string. */ + fun string() = values[state].toLowerCase() +} + +/** + * Filter representing a manga category. + * + * @property name The display name of the category. + * @constructor Creates a [Filter.TriState] object using [name]. + */ +class Category(name: String) : Filter.TriState(name) { + /** Returns the [state] as a string, or null if [isIgnored]. */ + fun stringOpt() = when(state) { + STATE_INCLUDE -> name.toLowerCase() + STATE_EXCLUDE -> "-" + name.toLowerCase() + else -> null + } +} + +/** + * Filter representing the [categories][Category] of a manga. + * + * @constructor Creates a [Filter.Group] object with [CATEGORIES]. + */ +class CategoryList : Filter.Group("Categories", CATEGORIES) + +/** + * Filter representing the name of an author or artist. + * + * @constructor Creates a [Filter.Text] object. + */ +class Person : Filter.Text("Author/Artist") + diff --git a/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ARUtils.kt b/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ARUtils.kt new file mode 100644 index 000000000..5d2fd887a --- /dev/null +++ b/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ARUtils.kt @@ -0,0 +1,89 @@ +package eu.kanade.tachiyomi.extension.en.arcrelight + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import org.json.JSONArray +import org.json.JSONObject +import java.lang.IllegalArgumentException +import java.text.DecimalFormat +import java.text.SimpleDateFormat +import java.util.Locale + +/** + * The HTTP date format specified in + * [RFC 1123](https://tools.ietf.org/html/rfc1123#page-55). + */ +private const val HTTP_DATE = "EEE, dd MMM yyyy HH:mm:ss zzz" + +/** + * Converts a date in the [HTTP_DATE] format to a Unix timestamp. + * + * @param date The date to convert. + * @return The timestamp of the date. + */ +fun httpDateToTimestamp(date: String) = + SimpleDateFormat(HTTP_DATE, Locale.US).parse(date).time + +/** + * Joins each value of a given [field] of the array using [sep]. + * + * @param field + * When its type is [Int], it is treated as the index of a [JSONArray]. + * When its type is [String], it is treated as the key of a [JSONObject]. + * @param sep The separator used to join the array. + * @return The joined string, or null if the array is empty. + * @throws IllegalArgumentException when [field] is of an invalid type. + */ +fun JSONArray.joinField(field: Any, sep: String = ", "): String? { + if (!(field is Int || field is String)) + throw IllegalArgumentException("field must be a String or Int") + if (this.length() == 0) return null + val list = mutableListOf() + for (i in 0 until this.length()) { + when (field) { + is Int -> list.add(this.getJSONArray(i).getString(field)) + is String -> list.add(this.getJSONObject(i).getString(field)) + } + } + return list.joinToString(sep) +} + +/** + * Creates a [SManga] by parsing a [JSONObject]. + * + * @param obj The object containing the manga info. + */ +fun SManga.fromJSON(obj: JSONObject) { + url = obj.getString("url") + title = obj.getString("title") + description = obj.getString("description") + thumbnail_url = obj.getString("cover") + author = obj.getJSONArray("authors")?.joinField(0) + artist = obj.getJSONArray("artists")?.joinField(0) + genre = obj.getJSONArray("categories")?.joinField("name") + status = when (obj.getBoolean("completed")) { + true -> SManga.COMPLETED + false -> SManga.ONGOING + } +} + +/** + * Creates a [SChapter] by parsing a [JSONObject]. + * + * @param obj The object containing the chapter info. + */ +fun SChapter.fromJSON(obj: JSONObject) { + url = obj.getString("url") + chapter_number = obj.getString("chapter").toFloat() + date_upload = httpDateToTimestamp(obj.getString("date")) + scanlator = obj.getJSONArray("groups")?.joinField("name", " & ") + val vol = obj.getString("volume") + val ch = DecimalFormat("0.#").format(chapter_number) + name = buildString { + if (vol != "0") append("Vol.$vol ") + append("Ch.$ch - ") + append(obj.getString("title")) + if (obj.getBoolean("final")) append(" [END]") + } +} + diff --git a/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ArcRelight.kt b/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ArcRelight.kt new file mode 100644 index 000000000..c3a924a0e --- /dev/null +++ b/src/en/arcrelight/src/eu/kanade/tachiyomi/extension/en/arcrelight/ArcRelight.kt @@ -0,0 +1,149 @@ +package eu.kanade.tachiyomi.extension.en.arcrelight + +import android.net.Uri +import android.os.Build.VERSION +import eu.kanade.tachiyomi.extension.BuildConfig +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.HttpSource +import org.json.JSONArray +import org.json.JSONObject +import okhttp3.Headers +import okhttp3.Request +import okhttp3.Response + +/** Arc-Relight source */ +class ArcRelight : HttpSource() { + override val versionId = 1 + + override val name = "Arc-Relight" + + override val baseUrl = "https://arc-relight.site/api/v$versionId" + + override val lang = "en" + + override val supportsLatest = true + + /** + * A user agent representing Tachiyomi. + * Includes the user's Android version + * and the current extension version. + */ + private val userAgent = "Mozilla/5.0 (" + + "Android ${VERSION.RELEASE}; Mobile) " + + "Tachiyomi/${BuildConfig.VERSION_NAME}" + + override fun headersBuilder() = Headers.Builder().apply { + add("User-Agent", userAgent) + add("Referer", baseUrl) + } + + override fun latestUpdatesRequest(page: Int) = + GET("$baseUrl/releases/", headers) + + override fun pageListRequest(chapter: SChapter) = + GET(Uri.parse(chapter.url).path.replace( + "/reader/", "$baseUrl/series/"), headers) + + override fun mangaDetailsRequest(manga: SManga) = + GET("$baseUrl/series/${manga.url.split("/") + .last { it != "" }}/", headers) + + override fun chapterListRequest(manga: SManga) = + mangaDetailsRequest(manga) + + override fun searchMangaRequest(page: Int, query: String, + filters: FilterList): Request { + val uri = Uri.parse("$baseUrl/series/").buildUpon() + uri.appendQueryParameter("q", query) + val cat = mutableListOf() + filters.forEach { + when (it) { + is Person -> uri.appendQueryParameter("author", it.state) + is Status -> uri.appendQueryParameter("status", it.string()) + is CategoryList -> cat.addAll(it.state.mapNotNull { + c -> Uri.encode(c.stringOpt()) + }) + } + } + return GET("$uri&categories=${cat.joinToString(",")}", headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + val arr = JSONArray(response.body()!!.string()) + val ret = ArrayList(arr.length()) + for (i in 0 until arr.length()) { + val obj = arr.getJSONObject(i) + ret.add(SManga.create().apply { + url = obj.getString("url") + title = obj.getString("title") + thumbnail_url = obj.getString("cover") + }) + } + return MangasPage(ret, false) + } + + override fun chapterListParse(response: Response): List { + val res = JSONObject(response.body()!!.string()) + val volumes = res.getJSONObject("volumes") + val ret = mutableListOf() + volumes.keys().forEach { vol -> + val chapters = volumes.getJSONObject(vol) + chapters.keys().forEach { ch -> + val obj = chapters.getJSONObject(ch) + obj.put("chapter", ch) + obj.put("volume", vol) + ret.add(SChapter.create().apply { fromJSON(obj) }) + } + } + return ret.sortedByDescending { it.name } + } + + override fun mangaDetailsParse(response: Response) = + SManga.create().apply { + fromJSON(JSONObject(response.body()!!.string())) + } + + override fun pageListParse(response: Response): List { + val obj = JSONObject(response.body()!!.string()) + val url = obj.getString("url") + val root = obj.getString("pages_root") + val arr = obj.getJSONArray("pages_list") + val ret = mutableListOf() + for (i in 0 until arr.length()) { + ret.add(Page(i, "$url${i + 1}", root + arr.getString(i))) + } + return ret + } + + override fun searchMangaParse(response: Response): MangasPage { + val arr = JSONArray(response.body()!!.string()) + val ret = mutableListOf() + for (i in 0 until arr.length()) { + ret.add(SManga.create().apply { + fromJSON(arr.getJSONObject(i)) + }) + } + return MangasPage(ret.sortedBy { it.title }, false) + } + + override fun getFilterList() = FilterList( + Person(), Status(), CategoryList() + ) + + override fun fetchPopularManga(page: Int) = + fetchSearchManga(page, "", FilterList()) + + override fun popularMangaRequest(page: Int) = + throw UnsupportedOperationException( + "This method should not be called!") + + override fun popularMangaParse(response: Response) = + throw UnsupportedOperationException( + "This method should not be called!") + + override fun imageUrlParse(response: Response) = + throw UnsupportedOperationException( + "This method should not be called!") +} +