Add Remanga source (#3363)
* The start Remanga * Remanga page list and branches * Remove paid chapters from list * Fix "chapterName" and add "Headers" * Remanga: chapter name with dot. Correct html description * Remanga: Fix chapters with float chapter name * Remanga: Parse type of manga * Filters Co-authored-by: pavkazzz <me@pavkazzz.ru>
This commit is contained in:
parent
ca8e45938e
commit
4fce627ae1
|
@ -0,0 +1,12 @@
|
||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
|
ext {
|
||||||
|
appName = 'Tachiyomi: Remanga'
|
||||||
|
pkgNameSuffix = 'ru.remanga'
|
||||||
|
extClass = '.Remanga'
|
||||||
|
extVersionCode = 1
|
||||||
|
libVersion = '1.2'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
After Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 70 KiB |
|
@ -0,0 +1,306 @@
|
||||||
|
package eu.kanade.tachiyomi.extension.ru.remanga
|
||||||
|
|
||||||
|
import BookDto
|
||||||
|
import BranchesDto
|
||||||
|
import GenresDto
|
||||||
|
import LibraryDto
|
||||||
|
import MangaDetDto
|
||||||
|
import PageDto
|
||||||
|
import PageWrapperDto
|
||||||
|
import SeriesWrapperDto
|
||||||
|
import com.github.salomonbrys.kotson.fromJson
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import eu.kanade.tachiyomi.network.GET
|
||||||
|
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||||
|
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 java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import rx.Observable
|
||||||
|
class Remanga : HttpSource() {
|
||||||
|
override val name = "Remanga"
|
||||||
|
|
||||||
|
override val baseUrl = "https://remanga.org"
|
||||||
|
|
||||||
|
override val lang = "ru"
|
||||||
|
|
||||||
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override fun headersBuilder() = Headers.Builder().apply {
|
||||||
|
add("User-Agent", "Tachiyomi")
|
||||||
|
add("Referer", baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val count = 30
|
||||||
|
|
||||||
|
private var branches = mutableMapOf<String, List<BranchesDto>>()
|
||||||
|
|
||||||
|
override fun popularMangaRequest(page: Int) = GET("$baseUrl/api/search/catalog/?ordering=rating&count=$count&page=$page", headers)
|
||||||
|
|
||||||
|
override fun popularMangaParse(response: Response): MangasPage = searchMangaParse(response)
|
||||||
|
|
||||||
|
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/api/titles/last-chapters/?page=$page&count=$count", headers)
|
||||||
|
|
||||||
|
override fun latestUpdatesParse(response: Response): MangasPage = searchMangaParse(response)
|
||||||
|
|
||||||
|
override fun searchMangaParse(response: Response): MangasPage {
|
||||||
|
val page = gson.fromJson<PageWrapperDto<LibraryDto>>(response.body()?.charStream()!!)
|
||||||
|
val mangas = page.content.map {
|
||||||
|
it.toSManga()
|
||||||
|
}
|
||||||
|
return MangasPage(mangas, !page.last)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun LibraryDto.toSManga(): SManga =
|
||||||
|
SManga.create().apply {
|
||||||
|
title = en_name
|
||||||
|
url = "/api/titles/$dir/"
|
||||||
|
thumbnail_url = "$baseUrl/${img.high}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseDate(date: String?): Long =
|
||||||
|
if (date == null)
|
||||||
|
Date().time
|
||||||
|
else {
|
||||||
|
try {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).parse(date).time
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
try {
|
||||||
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S", Locale.US).parse(date).time
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
Date().time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||||
|
var url = HttpUrl.parse("$baseUrl/api/search/catalog/?page=$page")!!.newBuilder()
|
||||||
|
if (query.isNotEmpty()) {
|
||||||
|
url = HttpUrl.parse("$baseUrl/api/search/?page=$page")!!.newBuilder()
|
||||||
|
url.addQueryParameter("query", query)
|
||||||
|
}
|
||||||
|
(if (filters.isEmpty()) getFilterList() else filters).forEach { filter ->
|
||||||
|
when (filter) {
|
||||||
|
is CategoryList -> filter.state.forEach { category ->
|
||||||
|
if (category.state != Filter.TriState.STATE_IGNORE) {
|
||||||
|
url.addQueryParameter(if (category.isIncluded()) "types" else "exclude_types", category.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is StatusList -> filter.state.forEach { status ->
|
||||||
|
if (status.state != false) {
|
||||||
|
url.addQueryParameter("status", status.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is GenreList -> filter.state.forEach { genre ->
|
||||||
|
if (genre.state != Filter.TriState.STATE_IGNORE) {
|
||||||
|
url.addQueryParameter(if (genre.isIncluded()) "genres" else "exclude_genres", genre.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is OrderBy -> {
|
||||||
|
var ord = arrayOf("id", "chapter_date", "rating", "votes", "views", "random")[filter.state!!.index]
|
||||||
|
if (!filter.state!!.ascending) {
|
||||||
|
ord = "-" + ord
|
||||||
|
}
|
||||||
|
url.addQueryParameter("ordering", ord)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return GET(url.toString(), headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseStatus(status: Int): Int {
|
||||||
|
return when (status) {
|
||||||
|
0 -> SManga.COMPLETED
|
||||||
|
1 -> SManga.ONGOING
|
||||||
|
else -> SManga.UNKNOWN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseType(type: GenresDto): GenresDto {
|
||||||
|
return when (type.name) {
|
||||||
|
"Западный комикс" -> GenresDto(type.id, "Комикс")
|
||||||
|
else -> type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun MangaDetDto.toSManga(): SManga {
|
||||||
|
val o = this
|
||||||
|
return SManga.create().apply {
|
||||||
|
title = en_name
|
||||||
|
url = "/api/titles/$dir/"
|
||||||
|
thumbnail_url = "$baseUrl/${img.high}"
|
||||||
|
this.description = Jsoup.parse(o.description).text()
|
||||||
|
genre = (genres + parseType(type)).joinToString { it.name }
|
||||||
|
status = parseStatus(o.status.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun mangaDetailsParse(response: Response): SManga {
|
||||||
|
val series = gson.fromJson<SeriesWrapperDto<MangaDetDto>>(response.body()?.charStream()!!)
|
||||||
|
branches[series.content.en_name] = series.content.branches
|
||||||
|
return series.content.toSManga()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mangaBranches(manga: SManga): List<BranchesDto> {
|
||||||
|
val response = client.newCall(GET("$baseUrl/${manga.url}")).execute()
|
||||||
|
val series = gson.fromJson<SeriesWrapperDto<MangaDetDto>>(response.body()?.charStream()!!)
|
||||||
|
branches[series.content.en_name] = series.content.branches
|
||||||
|
return series.content.branches
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||||
|
val branch = branches.getOrElse(manga.title) { mangaBranches(manga) }
|
||||||
|
return if (manga.status != SManga.LICENSED) {
|
||||||
|
// Use only first branch for all cases
|
||||||
|
client.newCall(chapterListRequest(branch[0].id))
|
||||||
|
.asObservableSuccess()
|
||||||
|
.map { response ->
|
||||||
|
chapterListParse(response)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Observable.error(Exception("Licensed - No chapters to show"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chapterListRequest(branch: Long): Request {
|
||||||
|
return GET("$baseUrl/api/titles/chapters/?branch_id=$branch", headers)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun chapterName(book: BookDto): String {
|
||||||
|
val chapterId = if (book.chapter % 1 == 0f) book.chapter.toInt() else book.chapter
|
||||||
|
var chapterName = "${book.tome} - $chapterId"
|
||||||
|
if (book.name.isNotBlank() && chapterName != chapterName) {
|
||||||
|
chapterName += "- $chapterName"
|
||||||
|
}
|
||||||
|
return chapterName
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chapterListParse(response: Response): List<SChapter> {
|
||||||
|
val chapters = gson.fromJson<PageWrapperDto<BookDto>>(response.body()?.charStream()!!)
|
||||||
|
return chapters.content.filter { !it.is_paid }.map { chapter ->
|
||||||
|
SChapter.create().apply {
|
||||||
|
chapter_number = chapter.chapter
|
||||||
|
name = chapterName(chapter)
|
||||||
|
url = "/api/titles/chapters/${chapter.id}"
|
||||||
|
date_upload = parseDate(chapter.upload_date)
|
||||||
|
}
|
||||||
|
}.sortedByDescending { it.chapter_number }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun imageUrlParse(response: Response): String = ""
|
||||||
|
|
||||||
|
override fun pageListParse(response: Response): List<Page> {
|
||||||
|
val page = gson.fromJson<SeriesWrapperDto<PageDto>>(response.body()?.charStream()!!)
|
||||||
|
return page.content.pages.map {
|
||||||
|
Page(it.page, "", it.link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private class SearchFilter(name: String, val id: String) : Filter.TriState(name)
|
||||||
|
private class CheckFilter(name: String, val id: String) : Filter.CheckBox(name)
|
||||||
|
|
||||||
|
private class CategoryList(categories: List<SearchFilter>) : Filter.Group<SearchFilter>("Категории", categories)
|
||||||
|
private class StatusList(statuses: List<CheckFilter>) : Filter.Group<CheckFilter>("Статус", statuses)
|
||||||
|
private class GenreList(genres: List<SearchFilter>) : Filter.Group<SearchFilter>("Жанры", genres)
|
||||||
|
|
||||||
|
override fun getFilterList() = FilterList(
|
||||||
|
CategoryList(getCategoryList()),
|
||||||
|
StatusList(getStatusList()),
|
||||||
|
GenreList(getGenreList()),
|
||||||
|
OrderBy()
|
||||||
|
)
|
||||||
|
|
||||||
|
private class OrderBy : Filter.Sort("Сортировка",
|
||||||
|
arrayOf("Новизне", "Последним обновлениям", "Популярности", "Лайкам", "Просмотрам", "Мне повезет"),
|
||||||
|
Selection(2, false))
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use console
|
||||||
|
* Object.entries(__FILTER_ITEMS__.types).map(([k, v]) => `SearchFilter("${v.label}", "${v.id}")`).join(',\n')
|
||||||
|
* on /manga-list
|
||||||
|
*/
|
||||||
|
private fun getCategoryList() = listOf(
|
||||||
|
SearchFilter("Манга", "0"),
|
||||||
|
SearchFilter("Манхва", "1"),
|
||||||
|
SearchFilter("Маньхуа", "2"),
|
||||||
|
SearchFilter("Западный комикс", "3"),
|
||||||
|
SearchFilter("Русскомикс", "4"),
|
||||||
|
SearchFilter("Индонезийский комикс", "5"),
|
||||||
|
SearchFilter("Новелла", "6"),
|
||||||
|
SearchFilter("Другое", "7")
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use console
|
||||||
|
* Object.entries(__FILTER_ITEMS__.status).map(([k, v]) => `SearchFilter("${v.label}", "${v.id}")`).join(',\n')
|
||||||
|
* on /manga-list
|
||||||
|
*/
|
||||||
|
private fun getStatusList() = listOf(
|
||||||
|
CheckFilter("Закончен", "0"),
|
||||||
|
CheckFilter("Продолжается", "1"),
|
||||||
|
CheckFilter("Заморожен", "2")
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Use console
|
||||||
|
* __FILTER_ITEMS__.genres.map(it => `SearchFilter("${it.name}", "${it.id}")`).join(',\n')
|
||||||
|
* on /manga-list
|
||||||
|
*/
|
||||||
|
private fun getGenreList() = listOf(
|
||||||
|
SearchFilter("арт", "1"),
|
||||||
|
SearchFilter("бдсм", "44"),
|
||||||
|
SearchFilter("боевик", "2"),
|
||||||
|
SearchFilter("боевые искусства", "3"),
|
||||||
|
SearchFilter("вампиры", "4"),
|
||||||
|
SearchFilter("гарем", "5"),
|
||||||
|
SearchFilter("гендерная интрига", "6"),
|
||||||
|
SearchFilter("героическое фэнтези", "7"),
|
||||||
|
SearchFilter("детектив", "8"),
|
||||||
|
SearchFilter("дзёсэй", "9"),
|
||||||
|
SearchFilter("додзинси", "10"),
|
||||||
|
SearchFilter("драма", "11"),
|
||||||
|
SearchFilter("игра", "12"),
|
||||||
|
SearchFilter("история", "13"),
|
||||||
|
SearchFilter("киберпанк", "14"),
|
||||||
|
SearchFilter("кодомо", "15"),
|
||||||
|
SearchFilter("комедия", "16"),
|
||||||
|
SearchFilter("махо-сёдзё", "17"),
|
||||||
|
SearchFilter("меха", "18"),
|
||||||
|
SearchFilter("мистика", "19"),
|
||||||
|
SearchFilter("научная фантастика", "20"),
|
||||||
|
SearchFilter("повседневность", "21"),
|
||||||
|
SearchFilter("постапокалиптика", "22"),
|
||||||
|
SearchFilter("приключения", "23"),
|
||||||
|
SearchFilter("психология", "24"),
|
||||||
|
SearchFilter("романтика", "25"),
|
||||||
|
SearchFilter("сверхъестественное", "27"),
|
||||||
|
SearchFilter("сёдзё", "28"),
|
||||||
|
SearchFilter("сёдзё-ай", "29"),
|
||||||
|
SearchFilter("сёнэн", "30"),
|
||||||
|
SearchFilter("сёнэн-ай", "31"),
|
||||||
|
SearchFilter("спорт", "32"),
|
||||||
|
SearchFilter("сэйнэн", "33"),
|
||||||
|
SearchFilter("трагедия", "34"),
|
||||||
|
SearchFilter("триллер", "35"),
|
||||||
|
SearchFilter("ужасы", "36"),
|
||||||
|
SearchFilter("фантастика", "37"),
|
||||||
|
SearchFilter("фэнтези", "38"),
|
||||||
|
SearchFilter("школа", "39"),
|
||||||
|
SearchFilter("эротика", "42"),
|
||||||
|
SearchFilter("этти", "40"),
|
||||||
|
SearchFilter("юри", "41"),
|
||||||
|
SearchFilter("яой", "43")
|
||||||
|
)
|
||||||
|
|
||||||
|
private val gson by lazy { Gson() }
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
data class GenresDto(
|
||||||
|
val id: Int,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
data class BranchesDto(
|
||||||
|
val id: Long
|
||||||
|
)
|
||||||
|
data class ImgDto(
|
||||||
|
val high: String,
|
||||||
|
val mid: String,
|
||||||
|
val low: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LibraryDto(
|
||||||
|
val id: Long,
|
||||||
|
val en_name: String,
|
||||||
|
val rus_name: String,
|
||||||
|
val dir: String,
|
||||||
|
val issue_year: Int,
|
||||||
|
val genres: List<GenresDto>,
|
||||||
|
val img: ImgDto
|
||||||
|
)
|
||||||
|
|
||||||
|
data class StatusDto(
|
||||||
|
val id: Int,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class MangaDetDto(
|
||||||
|
val id: Long,
|
||||||
|
val en_name: String,
|
||||||
|
val rus_name: String,
|
||||||
|
val dir: String,
|
||||||
|
val description: String,
|
||||||
|
val issue_year: Int,
|
||||||
|
val img: ImgDto,
|
||||||
|
val type: GenresDto,
|
||||||
|
val genres: List<GenresDto>,
|
||||||
|
val branches: List<BranchesDto>,
|
||||||
|
val status: StatusDto
|
||||||
|
)
|
||||||
|
data class PropsDto(
|
||||||
|
val total_items: Int,
|
||||||
|
val total_pages: Int,
|
||||||
|
val page: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PageWrapperDto<T>(
|
||||||
|
val msg: String,
|
||||||
|
val content: List<T>,
|
||||||
|
val props: PropsDto,
|
||||||
|
val last: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SeriesWrapperDto<T>(
|
||||||
|
val msg: String,
|
||||||
|
val content: T,
|
||||||
|
val props: PropsDto
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BookDto(
|
||||||
|
val id: Long,
|
||||||
|
val tome: Int,
|
||||||
|
val chapter: Float,
|
||||||
|
val name: String,
|
||||||
|
val upload_date: String,
|
||||||
|
val is_paid: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class PagesDto(
|
||||||
|
val id: Int,
|
||||||
|
val link: String,
|
||||||
|
val page: Int,
|
||||||
|
val count_comments: Int
|
||||||
|
)
|
||||||
|
data class PageDto(
|
||||||
|
val pages: List<PagesDto>
|
||||||
|
)
|
Loading…
Reference in New Issue