diff --git a/src/fr/scanr/AndroidManifest.xml b/src/fr/scanr/AndroidManifest.xml new file mode 100644 index 000000000..07b46f265 --- /dev/null +++ b/src/fr/scanr/AndroidManifest.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/src/fr/scanr/build.gradle b/src/fr/scanr/build.gradle new file mode 100644 index 000000000..b93015e55 --- /dev/null +++ b/src/fr/scanr/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'ScanR' + extClass = '.ScanR' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/fr/scanr/res/mipmap-hdpi/ic_launcher.png b/src/fr/scanr/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..be7d05be0 Binary files /dev/null and b/src/fr/scanr/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/fr/scanr/res/mipmap-mdpi/ic_launcher.png b/src/fr/scanr/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..6bb1c30f4 Binary files /dev/null and b/src/fr/scanr/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/fr/scanr/res/mipmap-xhdpi/ic_launcher.png b/src/fr/scanr/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..a979fb775 Binary files /dev/null and b/src/fr/scanr/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/fr/scanr/res/mipmap-xxhdpi/ic_launcher.png b/src/fr/scanr/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..effbc4993 Binary files /dev/null and b/src/fr/scanr/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/fr/scanr/res/mipmap-xxxhdpi/ic_launcher.png b/src/fr/scanr/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..a4e37722f Binary files /dev/null and b/src/fr/scanr/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanR.kt b/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanR.kt new file mode 100644 index 000000000..6bc6d7262 --- /dev/null +++ b/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanR.kt @@ -0,0 +1,195 @@ +package eu.kanade.tachiyomi.extension.fr.scanr + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +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 keiyoushi.utils.parseAs +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import java.net.URI + +class ScanR : HttpSource() { + + override val name = "ScanR" + override val baseUrl = "https://teamscanr.fr" + val cdnUrl = "https://cdn.teamscanr.fr" + override val lang = "fr" + override val supportsLatest = false + private val seriesDataCache = mutableMapOf() + + // Popular + override fun popularMangaRequest(page: Int): Request { + return GET("$cdnUrl/index.json", headers) + } + + override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response) + + // Search + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = if (query.isNotBlank()) { + "$cdnUrl/index.json#$query" + } else { + "$cdnUrl/index.json" + } + return GET(url, headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + val series = response.parseAs>() + val mangaList = mutableListOf() + + val fragment = response.request.url.fragment + val searchQuery = fragment ?: "" + + if (searchQuery.startsWith("SLUG:")) { + val filename = series.get(searchQuery.removePrefix("SLUG:")) + if (filename != null) { + val serie = fetchSeriesData(filename) + mangaList.add(serie.toDetailedSManga()) + } + return MangasPage(mangaList, false) + } + + for ((slug, filename) in series) { + val serie = fetchSeriesData(filename) + + if (searchQuery.isBlank() || serie.title.contains(searchQuery, ignoreCase = true)) { + mangaList.add(serie.toDetailedSManga()) + } + } + + return MangasPage(mangaList, false) + } + + // Details + override fun fetchMangaDetails(manga: SManga): Observable { + val splitedPath = URI(manga.url).path.split("/") + val slug = splitedPath[1] + return client.newCall(GET("$cdnUrl/index.json", headers)) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response, slug) + } + } + + private fun mangaDetailsParse(response: Response, slug: String = ""): SManga { + val map = response.parseAs>() + val serie = fetchSeriesData(map.get(slug)!!, false) + return serie.toDetailedSManga() + } + + // Pages + override fun pageListRequest(chapter: SChapter): Request { + val splitedPath = URI(chapter.url).path.split("/") + val slug = splitedPath[1] + val chapterId = splitedPath[2] + val serie = getSerieFromSlug(slug) + val chapterDetails = serie.chapters[chapterId.replace("-", ".")] + val cubariProxy = chapterDetails!!.groups.getValue(chapterDetails.groups.keys.first()) + return GET("https://cubari.moe$cubariProxy", headers) + } + + override fun pageListParse(response: Response): List { + val images = response.parseAs>() + return images.mapIndexed { index, pageData -> + Page(index, imageUrl = pageData) + } + } + + // Chapters + override fun fetchChapterList(manga: SManga): Observable> { + val splitedPath = URI(manga.url).path.split("/") + val slug = splitedPath[1] + return client.newCall(GET("$cdnUrl/index.json", headers)) + .asObservableSuccess() + .map { response -> + chapterListParse(response, slug) + } + } + + private fun chapterListParse(response: Response, slug: String = ""): List { + val filename = response.parseAs>().get(slug)!! + val series = fetchSeriesData(filename) + return buildChapterList(series) + } + + private fun buildChapterList(serie: Serie): List { + val chapters = serie.chapters + val chapterList = mutableListOf() + + for ((chapterNumber, chapterData) in chapters) { + val title = chapterData.title + val volumeNumber = chapterData.volume + + val baseName = if (!serie.os) { + buildString { + if (volumeNumber.isNotBlank()) append("Vol. $volumeNumber ") + append("Ch. $chapterNumber") + if (title.isNotBlank()) append(" – $title") + } + } else { + if (title.isNotBlank()) "One Shot – $title" else "One Shot" + } + + val chapter = SChapter.create().apply { + name = baseName + setUrlWithoutDomain("$baseUrl/${serie.slug}/${chapterNumber.replace(".","-")}") + chapter_number = chapterNumber.toFloatOrNull() ?: -1f + scanlator = chapterData.groups.keys.first() + date_upload = chapterData.lastUpdated.toLong() * 1000L + } + chapterList.add(chapter) + } + + return chapterList.sortedByDescending { it.chapter_number } + } + + // Series utils + private fun fetchSeriesData(filename: String, forceReload: Boolean = false): Serie { + val cachedSerie = seriesDataCache[filename] + if (!forceReload && cachedSerie != null) { + return cachedSerie + } + + val response = client.newCall(GET("$cdnUrl/$filename", headers)).execute() + val seriesData = response.parseAs() + + seriesDataCache[filename] = seriesData + return seriesData + } + + private fun getSerieFromSlug(slug: String, forceReload: Boolean = false): Serie { + val serieList = + client.newCall(GET("$cdnUrl/index.json", headers)) + .execute().parseAs>() + + return fetchSeriesData(serieList[slug] ?: "", forceReload) + } + + // Unsupported stuff + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException() + } + + override fun latestUpdatesParse(response: Response): MangasPage { + throw UnsupportedOperationException() + } + + override fun latestUpdatesRequest(page: Int): Request { + throw UnsupportedOperationException() + } + + override fun chapterListParse(response: Response): List { + throw UnsupportedOperationException() + } + + override fun mangaDetailsParse(response: Response): SManga { + throw UnsupportedOperationException() + } +} diff --git a/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanRDto.kt b/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanRDto.kt new file mode 100644 index 000000000..731034a1d --- /dev/null +++ b/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanRDto.kt @@ -0,0 +1,43 @@ +package eu.kanade.tachiyomi.extension.fr.scanr + +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Data Transfer Objects for TeamScanR extension + */ + +@Serializable +class Serie( + val slug: String, + val title: String, + val description: String, + val artist: String, + val author: String, + val cover: String, + val os: Boolean = false, + val chapters: Map, + val completed: Boolean = false, + val konami: Boolean = false, +) + +@Serializable +class Chapter( + val title: String, + val volume: String, + @SerialName("last_updated") + val lastUpdated: String, + val groups: Map, +) + +// DTO to SManga extension functions +fun Serie.toDetailedSManga(): SManga = SManga.create().apply { + title = (if (this@toDetailedSManga.konami == true) "[+18] " else "") + this@toDetailedSManga.title + description = this@toDetailedSManga.description + artist = this@toDetailedSManga.artist + author = this@toDetailedSManga.author + status = if (this@toDetailedSManga.os || this@toDetailedSManga.completed) SManga.COMPLETED else SManga.ONGOING + thumbnail_url = this@toDetailedSManga.cover + url = "/${this@toDetailedSManga.slug}" +} diff --git a/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanRUrlActivity.kt b/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanRUrlActivity.kt new file mode 100644 index 000000000..1b85c0111 --- /dev/null +++ b/src/fr/scanr/src/eu/kanade/tachiyomi/extension/fr/scanr/ScanRUrlActivity.kt @@ -0,0 +1,38 @@ +package eu.kanade.tachiyomi.extension.fr.scanr + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +/** + * Springboard that accepts https://teamscanr.fr/xxxxxx intents and redirects them to + * the main Tachiyomi process. + */ +class ScanRUrlActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size >= 1) { + val slug = pathSegments[0] + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", "SLUG:$slug") + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("ScanRUrlActivity", e.toString()) + } + } else { + Log.e("ScanRUrlActivity", "could not parse uri from intent $intent") + } + + finish() + exitProcess(0) + } +}