diff --git a/src/pt/blackscans/AndroidManifest.xml b/src/pt/blackscans/AndroidManifest.xml new file mode 100644 index 000000000..8c685c35c --- /dev/null +++ b/src/pt/blackscans/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/pt/blackscans/build.gradle b/src/pt/blackscans/build.gradle new file mode 100644 index 000000000..caf40184e --- /dev/null +++ b/src/pt/blackscans/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'Black Scans' + extClass = '.BlackScans' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/pt/blackscans/res/mipmap-hdpi/ic_launcher.png b/src/pt/blackscans/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..dbbb59182 Binary files /dev/null and b/src/pt/blackscans/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/pt/blackscans/res/mipmap-mdpi/ic_launcher.png b/src/pt/blackscans/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..ef73bba4b Binary files /dev/null and b/src/pt/blackscans/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/pt/blackscans/res/mipmap-xhdpi/ic_launcher.png b/src/pt/blackscans/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..03373237c Binary files /dev/null and b/src/pt/blackscans/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/pt/blackscans/res/mipmap-xxhdpi/ic_launcher.png b/src/pt/blackscans/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..41df90443 Binary files /dev/null and b/src/pt/blackscans/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/pt/blackscans/res/mipmap-xxxhdpi/ic_launcher.png b/src/pt/blackscans/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..8e355c5c3 Binary files /dev/null and b/src/pt/blackscans/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/BlackScans.kt b/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/BlackScans.kt new file mode 100644 index 000000000..d908b87af --- /dev/null +++ b/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/BlackScans.kt @@ -0,0 +1,179 @@ +package eu.kanade.tachiyomi.extension.pt.blackscans + +import android.annotation.SuppressLint +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +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.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromStream +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okio.Buffer +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat + +class BlackScans : HttpSource() { + + override val name = "Black Scans" + + override val baseUrl = "https://blackscans.site" + + override val lang = "pt-BR" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimitHost(API_URL.toHttpUrl(), 2) + .build() + + private val json: Json by injectLazy() + + // ============================== Popular ============================== + + override fun popularMangaRequest(page: Int) = GET("$API_URL/api/series/", headers) + + override fun popularMangaParse(response: Response): MangasPage { + val mangas = response.parseAs>().map { manga -> + SManga.create().apply { + title = manga.title + thumbnail_url = "$API_URL/media/${manga.cover}" + url = "/series/${manga.code}" + } + } + return MangasPage(mangas, false) + } + + // ============================== Latest ============================== + + override fun latestUpdatesRequest(page: Int) = GET("$API_URL/api/series/updates/", headers) + + override fun latestUpdatesParse(response: Response) = popularMangaParse(response) + + // ============================== Search ============================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = popularMangaRequest(page) + + override fun searchMangaParse(response: Response) = popularMangaParse(response) + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + if (query.startsWith(PREFIX_SEARCH)) { + val mangaCode = query.substringAfter(PREFIX_SEARCH) + return fetchMangaDetails(SManga.create().apply { url = "/series/$mangaCode" }) + .map { manga -> MangasPage(listOf(manga), false) } + } + + return super.fetchSearchManga(page, query, filters).map { mangasPage -> + val mangas = mangasPage.mangas.filter { manga -> manga.title.contains(query, true) } + mangasPage.copy(mangas) + } + } + + // ============================== Details ============================= + + override fun getMangaUrl(manga: SManga) = "$baseUrl${manga.url}" + + override fun mangaDetailsRequest(manga: SManga) = + POST("$API_URL/api/serie/", headers, manga.createPostPayload()) + + override fun mangaDetailsParse(response: Response): SManga { + return response.parseAs().let { dto -> + SManga.create().apply { + title = dto.title + description = dto.synopsis + thumbnail_url = "$API_URL/media/${dto.cover}" + author = dto.author + artist = dto.artist + genre = dto.genres.joinToString() + url = "/series/${dto.code}" + status = dto.status.toMangaStatus() + } + } + } + + private fun String.toMangaStatus(): Int { + return when (this.lowercase()) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + + // ============================== Chapters ============================ + + override fun getChapterUrl(chapter: SChapter) = "$baseUrl${chapter.url}" + + override fun chapterListRequest(manga: SManga): Request { + val payload = manga.createPostPayload("series_code") + return POST("$API_URL/api/series/chapters/", headers, payload) + } + + override fun chapterListParse(response: Response): List { + val series = response.request.body!!.parseAs() + + return response.parseAs().chapters.map { chapter -> + SChapter.create().apply { + name = chapter.name + date_upload = chapter.uploadAt.toDate() + url = "/series/${series.code}/${chapter.code}" + } + } + } + + // ============================== Pages =============================== + + override fun imageUrlParse(response: Response) = "" + + override fun pageListRequest(chapter: SChapter): Request { + val chapterCode = chapter.url.substringAfterLast("/") + val payload = """{"chapter_code":"$chapterCode"}""" + .toRequestBody("application/json".toMediaType()) + return POST("$API_URL/api/chapter/info/", headers, payload) + } + + override fun pageListParse(response: Response): List { + return response.parseAs().images.mapIndexed { index, imageUrl -> + Page(index, imageUrl = "$API_URL//media/$imageUrl") + } + } + + // ============================== Utils =============================== + + @Serializable + private class SeriesDto(@SerialName("series_code") val code: String) + + private fun SManga.createPostPayload(field: String = "code"): RequestBody { + val mangaCode = url.substringAfterLast("/") + return """{"$field": "$mangaCode"}""".toRequestBody("application/json".toMediaType()) + } + + private inline fun Response.parseAs(): T = use { + json.decodeFromStream(it.body.byteStream()) + } + private inline fun RequestBody.parseAs(): T = + json.decodeFromString(Buffer().also { writeTo(it) }.readUtf8()) + + private fun String.toDate() = + try { dateFormat.parse(this)!!.time } catch (_: Exception) { 0 } + + companion object { + const val API_URL = "https://api.blackscans.site" + const val PREFIX_SEARCH = "id:" + + @SuppressLint("SimpleDateFormat") + val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'") + } +} diff --git a/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/BlackScansUrlActivity.kt b/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/BlackScansUrlActivity.kt new file mode 100644 index 000000000..3f2b02e16 --- /dev/null +++ b/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/BlackScansUrlActivity.kt @@ -0,0 +1,37 @@ +package eu.kanade.tachiyomi.extension.pt.blackscans + +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 BlackScansUrlActivity : Activity() { + + private val tag = javaClass.simpleName + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 1) { + val item = pathSegments[1] + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "${BlackScans.PREFIX_SEARCH}$item") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e(tag, e.toString()) + } + } else { + Log.e(tag, "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } +} diff --git a/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/MangaDetailsDto.kt b/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/MangaDetailsDto.kt new file mode 100644 index 000000000..14b6cb167 --- /dev/null +++ b/src/pt/blackscans/src/eu/kanade/tachiyomi/extension/pt/blackscans/MangaDetailsDto.kt @@ -0,0 +1,44 @@ +package eu.kanade.tachiyomi.extension.pt.blackscans + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonNames + +@Serializable +class MangaDetailsDto( + val title: String, + val artist: String, + val author: String, + val code: String, + val genres: List, + @SerialName("path_cover") + val cover: String, + val status: String, + val synopsis: String, +) + +@Serializable +class MangaDto( + val code: String, + val title: String, + @JsonNames("path_cover") + val cover: String, +) + +@Serializable +class ChapterList( + val chapters: List, +) + +@Serializable +data class Chapter( + val code: String, + val name: String, + @SerialName("upload_date") + val uploadAt: String, +) + +@Serializable +class PagesDto( + val images: List, +)