diff --git a/multisrc/overrides/senkuro/senkognito/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkognito/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..d90ee4bd4 Binary files /dev/null and b/multisrc/overrides/senkuro/senkognito/res/mipmap-hdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkognito/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkognito/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..98f71077a Binary files /dev/null and b/multisrc/overrides/senkuro/senkognito/res/mipmap-mdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkognito/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkognito/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..ab84e7764 Binary files /dev/null and b/multisrc/overrides/senkuro/senkognito/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkognito/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkognito/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..30ebaf266 Binary files /dev/null and b/multisrc/overrides/senkuro/senkognito/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkognito/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkognito/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b95d69816 Binary files /dev/null and b/multisrc/overrides/senkuro/senkognito/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkognito/res/web_hi_res_512.png b/multisrc/overrides/senkuro/senkognito/res/web_hi_res_512.png new file mode 100644 index 000000000..8109d76c6 Binary files /dev/null and b/multisrc/overrides/senkuro/senkognito/res/web_hi_res_512.png differ diff --git a/multisrc/overrides/senkuro/senkognito/src/Senkognito.kt b/multisrc/overrides/senkuro/senkognito/src/Senkognito.kt new file mode 100644 index 000000000..dbc27203a --- /dev/null +++ b/multisrc/overrides/senkuro/senkognito/src/Senkognito.kt @@ -0,0 +1,36 @@ +package eu.kanade.tachiyomi.extension.ru.senkognito + +import android.app.Application +import android.content.SharedPreferences +import android.widget.Toast +import eu.kanade.tachiyomi.multisrc.senkuro.Senkuro +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class Senkognito : Senkuro("Senkognito", "https://senkognito.com", "ru") { + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private var domain: String? = if (preferences.getBoolean(redirect_PREF, true)) "https://senkognito.com" else "https://senkuro.com" + override val baseUrl: String = domain.toString() + override fun setupPreferenceScreen(screen: androidx.preference.PreferenceScreen) { + val domainRedirect = androidx.preference.CheckBoxPreference(screen.context).apply { + key = redirect_PREF + title = "Домен Senkognito" + summary = "Отключите если домен Senkognito недоступен в браузере/WebView." + setDefaultValue(true) + setOnPreferenceChangeListener { _, newValue -> + val warning = "Для смены домена необходимо перезапустить приложение с полной остановкой." + Toast.makeText(screen.context, warning, Toast.LENGTH_LONG).show() + true + } + } + screen.addPreference(domainRedirect) + } + + companion object { + private const val redirect_PREF = "domainRedirect" + } +} diff --git a/multisrc/overrides/senkuro/senkuro/res/mipmap-hdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkuro/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..c7b04fec4 Binary files /dev/null and b/multisrc/overrides/senkuro/senkuro/res/mipmap-hdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkuro/res/mipmap-mdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkuro/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..1452cbd4c Binary files /dev/null and b/multisrc/overrides/senkuro/senkuro/res/mipmap-mdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkuro/res/mipmap-xhdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkuro/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..37820c9a8 Binary files /dev/null and b/multisrc/overrides/senkuro/senkuro/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkuro/res/mipmap-xxhdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkuro/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..944881d33 Binary files /dev/null and b/multisrc/overrides/senkuro/senkuro/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkuro/res/mipmap-xxxhdpi/ic_launcher.png b/multisrc/overrides/senkuro/senkuro/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b4dac12d2 Binary files /dev/null and b/multisrc/overrides/senkuro/senkuro/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/multisrc/overrides/senkuro/senkuro/res/web_hi_res_512.png b/multisrc/overrides/senkuro/senkuro/res/web_hi_res_512.png new file mode 100644 index 000000000..359430854 Binary files /dev/null and b/multisrc/overrides/senkuro/senkuro/res/web_hi_res_512.png differ diff --git a/multisrc/overrides/senkuro/senkuro/src/Senkuro.kt b/multisrc/overrides/senkuro/senkuro/src/Senkuro.kt new file mode 100644 index 000000000..31a1645b5 --- /dev/null +++ b/multisrc/overrides/senkuro/senkuro/src/Senkuro.kt @@ -0,0 +1,8 @@ +package eu.kanade.tachiyomi.extension.ru.senkuro + +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.multisrc.senkuro.Senkuro + +class Senkuro : Senkuro("Senkuro", "https://senkuro.com", "ru") { + override fun setupPreferenceScreen(screen: PreferenceScreen) {} +} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/Dto.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/Dto.kt new file mode 100644 index 000000000..9437baf94 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/Dto.kt @@ -0,0 +1,128 @@ +package eu.kanade.tachiyomi.multisrc.senkuro + +import kotlinx.serialization.Serializable +@Serializable +data class PageWrapperDto( + val data: T, +) + +// Library Container +@Serializable +data class MangaTachiyomiSearchDto( + val mangaTachiyomiSearch: MangasDto, +) { + @Serializable + data class MangasDto( + val mangas: List, + ) +} + +// Manga Details +@Serializable +data class SubInfoDto( + val mangaTachiyomiInfo: MangaTachiyomiInfoDto, +) + +@Serializable +data class MangaTachiyomiInfoDto( + val id: String, + val slug: String, + val cover: SubImgDto? = null, + val status: String? = null, + val type: String? = null, + val rating: String? = null, + val formats: List? = null, + val genres: List? = null, + val tags: List? = null, + val titles: List, + val alternativeNames: List? = null, + val localizations: List? = null, + val mainStaff: List? = null, +) { + @Serializable + data class SubImgDto( + val original: ImgDto, + ) { + @Serializable + data class ImgDto( + val url: String? = null, + ) + } + + @Serializable + data class TagsDto( + val slug: String, + val titles: List, + ) + + @Serializable + data class TitleDto( + val lang: String, + val content: String, + ) + + @Serializable + data class LocalizationsDto( + val lang: String, + val description: String, + ) + + @Serializable + data class MainStaffDto( + val roles: List, + val person: PersonDto, + ) { + @Serializable + data class PersonDto( + val name: String, + ) + } +} + +// Chapters +@Serializable +data class MangaTachiyomiChaptersDto( + val mangaTachiyomiChapters: ChaptersMessage, +) { + @Serializable + data class ChaptersMessage( + val message: String? = null, + val chapters: List, + val teams: List, + ) { + @Serializable + data class BookDto( + val id: String, + val slug: String, + val branchId: String, + val name: String? = null, + val teamIds: List, + val number: String, + val volume: String, + val updatedAt: String, + ) + + @Serializable + data class TeamsDto( + val id: String, + val slug: String, + val name: String, + ) + } +} + +// Chapter Pages +@Serializable +data class MangaTachiyomiChapterPages( + val mangaTachiyomiChapterPages: ChaptersPages, +) { + @Serializable + data class ChaptersPages( + val pages: List, + ) { + @Serializable + data class UrlDto( + val url: String, + ) + } +} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/Senkuro.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/Senkuro.kt new file mode 100644 index 000000000..a60c82533 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/Senkuro.kt @@ -0,0 +1,440 @@ +package eu.kanade.tachiyomi.multisrc.senkuro + +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.interceptor.rateLimit +import eu.kanade.tachiyomi.source.ConfigurableSource +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +abstract class Senkuro( + override val name: String, + override val baseUrl: String, + final override val lang: String, +) : ConfigurableSource, HttpSource() { + + override val supportsLatest = false + + override fun headersBuilder(): Headers.Builder = Headers.Builder() + .add("User-Agent", "Tachiyomi (+https://github.com/tachiyomiorg/tachiyomi)") + .add("Content-Type", "application/json") + + override val client: OkHttpClient = + network.client.newBuilder() + .rateLimit(5) + .build() + + private inline fun T.toJsonRequestBody(): RequestBody = + json.encodeToString(this) + .toRequestBody(JSON_MEDIA_TYPE) + + // Popular + override fun popularMangaRequest(page: Int): Request { + val requestBody = GraphQL( + SEARCH_QUERY, + SearchVariables( + offset = offsetCount * (page - 1), + genre = SearchVariables.FiltersDto( + // Senkuro eternal built-in exclude 18+ filter + exclude = if (name == "Senkuro") { senkuroExcludeGenres } else { listOf() }, + ), + ), + ).toJsonRequestBody() + + fetchTachiyomiSearchFilters(page) + + return POST(API_URL, headers, requestBody) + } + override fun popularMangaParse(response: Response) = searchMangaParse(response) + + // Latest + override fun latestUpdatesRequest(page: Int): Request = throw NotImplementedError("Unused") + + override fun latestUpdatesParse(response: Response): MangasPage = throw NotImplementedError("Unused") + + // Search + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + fetchTachiyomiSearchFilters(page) // reset filters before sending searchMangaRequest + return client.newCall(searchMangaRequest(page, query, filters)) + .asObservableSuccess() + .map { response -> + searchMangaParse(response) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val includeGenres = mutableListOf() + val excludeGenres = mutableListOf() + val includeTags = mutableListOf() + val excludeTags = mutableListOf() + val includeTypes = mutableListOf() + val excludeTypes = mutableListOf() + val includeFormats = mutableListOf() + val excludeFormats = mutableListOf() + val includeStatus = mutableListOf() + val excludeStatus = mutableListOf() + val includeTStatus = mutableListOf() + val excludeTStatus = mutableListOf() + val includeAges = mutableListOf() + val excludeAges = mutableListOf() + + (if (filters.isEmpty()) getFilterList() else filters).forEach { filter -> + when (filter) { + is GenreList -> filter.state.forEach { genre -> + if (genre.state != Filter.TriState.STATE_IGNORE) { + if (genre.isIncluded()) includeGenres.add(genre.slug) else excludeGenres.add(genre.slug) + } + } + is TagList -> filter.state.forEach { tag -> + if (tag.state != Filter.TriState.STATE_IGNORE) { + if (tag.isIncluded()) includeTags.add(tag.slug) else excludeTags.add(tag.slug) + } + } + is TypeList -> filter.state.forEach { type -> + if (type.state != Filter.TriState.STATE_IGNORE) { + if (type.isIncluded()) includeTypes.add(type.slug) else excludeTypes.add(type.slug) + } + } + is FormatList -> filter.state.forEach { format -> + if (format.state != Filter.TriState.STATE_IGNORE) { + if (format.isIncluded()) includeFormats.add(format.slug) else excludeFormats.add(format.slug) + } + } + is StatList -> filter.state.forEach { stat -> + if (stat.state != Filter.TriState.STATE_IGNORE) { + if (stat.isIncluded()) includeStatus.add(stat.slug) else excludeStatus.add(stat.slug) + } + } + is StatTranslateList -> filter.state.forEach { tstat -> + if (tstat.state != Filter.TriState.STATE_IGNORE) { + if (tstat.isIncluded()) includeTStatus.add(tstat.slug) else excludeTStatus.add(tstat.slug) + } + } + is AgeList -> filter.state.forEach { age -> + if (age.state != Filter.TriState.STATE_IGNORE) { + if (age.isIncluded()) includeAges.add(age.slug) else excludeAges.add(age.slug) + } + } + else -> {} + } + } + + // Senkuro eternal built-in exclude 18+ filter + if (name == "Senkuro") { + excludeGenres.addAll(senkuroExcludeGenres) + } + + val requestBody = GraphQL( + SEARCH_QUERY, + SearchVariables( + query = query, offset = offsetCount * (page - 1), + genre = SearchVariables.FiltersDto( + includeGenres, + excludeGenres, + ), + tag = SearchVariables.FiltersDto( + includeTags, + excludeTags, + ), + type = SearchVariables.FiltersDto( + includeTypes, + excludeTypes, + ), + format = SearchVariables.FiltersDto( + includeFormats, + excludeFormats, + ), + status = SearchVariables.FiltersDto( + includeStatus, + excludeStatus, + ), + translationStatus = SearchVariables.FiltersDto( + includeTStatus, + excludeTStatus, + ), + rating = SearchVariables.FiltersDto( + includeAges, + excludeAges, + ), + ), + ).toJsonRequestBody() + + return POST(API_URL, headers, requestBody) + } + override fun searchMangaParse(response: Response): MangasPage { + val page = json.decodeFromString>>(response.body.string()) + val mangasList = page.data.mangaTachiyomiSearch.mangas.map { + it.toSManga() + } + + return MangasPage(mangasList, mangasList.isNotEmpty()) + } + + // Details + private fun parseStatus(status: String?): Int { + return when (status) { + "FINISHED" -> SManga.COMPLETED + "ONGOING" -> SManga.ONGOING + "HIATUS" -> SManga.ON_HIATUS + "ANNOUNCE" -> SManga.ONGOING + "CANCELLED" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + } + + private fun MangaTachiyomiInfoDto.toSManga(): SManga { + val o = this + return SManga.create().apply { + title = titles.find { it.lang == "RU" }?.content ?: titles.find { it.lang == "EN" }?.content ?: titles[0].content + url = "$id,,$slug" // mangaId[0],,mangaSlug[1] + thumbnail_url = cover?.original?.url + var altName = alternativeNames?.joinToString(" / ") { it.content } + if (!altName.isNullOrEmpty()) { + altName = "Альтернативные названия:\n$altName\n\n" + } + author = mainStaff?.filter { it.roles.contains("STORY") }?.joinToString(", ") { it.person.name } + artist = mainStaff?.filter { it.roles.contains("ART") }?.joinToString(", ") { it.person.name } + description = altName + localizations?.find { it.lang == "RU" }?.description.orEmpty() + status = parseStatus(o.status) + genre = ( + getTypeList().find { it.slug == type }?.name + ", " + + getAgeList().find { it.slug == rating }?.name + ", " + + getFormatList().filter { formats.orEmpty().contains(it.slug) }.joinToString { it.name } + ", " + + genres?.joinToString { git -> git.titles.find { it.lang == "RU" }!!.content } + ", " + + tags?.joinToString { tit -> tit.titles.find { it.lang == "RU" }!!.content } + ).split(", ").filter { it.isNotEmpty() }.joinToString { it.trim().capitalize() } + } + } + + override fun mangaDetailsRequest(manga: SManga): Request { + val requestBody = GraphQL( + DETAILS_QUERY, + FetchDetailsVariables(mangaId = manga.url.split(",,")[0]), + ).toJsonRequestBody() + + return POST(API_URL, headers, requestBody) + } + + override fun mangaDetailsParse(response: Response): SManga { + val series = json.decodeFromString>(response.body.string()) + return series.data.mangaTachiyomiInfo.toSManga() + } + + override fun getMangaUrl(manga: SManga) = baseUrl + "/manga/" + manga.url.split(",,")[1] + + // Chapters + private val simpleDateFormat by lazy { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.S", Locale.ROOT) } + + private fun parseDate(date: String?): Long { + date ?: return 0L + return try { + simpleDateFormat.parse(date)!!.time + } catch (_: Exception) { + Date().time + } + } + + override fun fetchChapterList(manga: SManga): Observable> { + return client.newCall(chapterListRequest(manga)) + .asObservableSuccess() + .map { response -> + chapterListParse(response, manga) + } + } + override fun chapterListParse(response: Response) = throw UnsupportedOperationException("chapterListParse(response: Response, manga: SManga)") + private fun chapterListParse(response: Response, manga: SManga): List { + val chaptersList = json.decodeFromString>(response.body.string()) + val teamsList = chaptersList.data.mangaTachiyomiChapters.teams + return chaptersList.data.mangaTachiyomiChapters.chapters.map { chapter -> + SChapter.create().apply { + chapter_number = chapter.number.toFloatOrNull() ?: -2F + name = "${chapter.volume}. Глава ${chapter.number} " + (chapter.name ?: "") + url = "${manga.url},,${chapter.id},,${chapter.slug}" // mangaId[0],,mangaSlug[1],,chapterId[2],,chapterSlug[3] + date_upload = parseDate(chapter.updatedAt) + scanlator = teamsList.filter { chapter.teamIds.contains(it.id) }.joinToString { it.name } + } + } + } + override fun chapterListRequest(manga: SManga): Request { + val requestBody = GraphQL( + CHAPTERS_QUERY, + FetchDetailsVariables(mangaId = manga.url.split(",,")[0]), + ).toJsonRequestBody() + + return POST(API_URL, headers, requestBody) + } + + // Pages + override fun pageListRequest(chapter: SChapter): Request { + val mangaChapterId = chapter.url.split(",,") + val requestBody = GraphQL( + CHAPTERS_PAGES_QUERY, + FetchChapterPagesVariables(mangaId = mangaChapterId[0], chapterId = mangaChapterId[2]), + ).toJsonRequestBody() + + return POST(API_URL, headers, requestBody) + } + + override fun getChapterUrl(chapter: SChapter): String { + val mangaChapterSlug = chapter.url.split(",,") + return baseUrl + "/manga/" + mangaChapterSlug[1] + "/chapters/" + mangaChapterSlug[3] + } + + override fun pageListParse(response: Response): List { + val imageList = json.decodeFromString>(response.body.string()) + return imageList.data.mangaTachiyomiChapterPages.pages.mapIndexed { index, page -> + Page(index, "", page.url) + } + } + + override fun imageUrlRequest(page: Page): Request = throw NotImplementedError("Unused") + + override fun imageUrlParse(response: Response): String = throw NotImplementedError("Unused") + + override fun fetchImageUrl(page: Page): Observable { + return Observable.just(page.url) + } + + // Filters + // Filters are fetched immediately once an extension loads + // We're only able to get filters after a loading the manga directory, and resetting + // the filters is the only thing that seems to reinflate the view + private fun fetchTachiyomiSearchFilters(pageRequest: Int) { + // The function must be used in PopularMangaRequest and fetchSearchManga to correctly/guaranteed reset the selected filters! + if (pageRequest == 1) { + val responseBody = client.newCall( + POST( + API_URL, + headers, + GraphQL( + FILTERS_QUERY, + SearchVariables(), + ).toJsonRequestBody(), + ), + ).execute().body.string() + + val filterDto = + json.decodeFromString>(responseBody).data.mangaTachiyomiSearchFilters + + genresList = + filterDto.genres.filterNot { name == "Senkuro" && senkuroExcludeGenres.contains(it.slug) } + .map { genre -> + FilterersTri( + genre.titles.find { it.lang == "RU" }!!.content.capitalize(), + genre.slug, + ) + } + + tagsList = filterDto.tags.map { tag -> + FilterersTri( + tag.titles.find { it.lang == "RU" }!!.content.capitalize(), + tag.slug, + ) + } + } + } + override fun getFilterList(): FilterList { + val filters = mutableListOf>() + filters += if (genresList.isEmpty() or tagsList.isEmpty()) { + listOf( + Filter.Separator(), + Filter.Header("Нажмите «Сбросить», чтобы загрузить все фильтры"), + Filter.Separator(), + ) + } else { + listOf( + GenreList(genresList), + TagList(tagsList), + ) + } + filters += listOf( + TypeList(getTypeList()), + FormatList(getFormatList()), + StatList(getStatList()), + StatTranslateList(getStatTranslateList()), + AgeList(getAgeList()), + ) + return FilterList(filters) + } + + private class FilterersTri(name: String, val slug: String) : Filter.TriState(name) + private class GenreList(genres: List) : Filter.Group("Жанры", genres) + private class TagList(tags: List) : Filter.Group("Тэги", tags) + private class TypeList(types: List) : Filter.Group("Тип", types) + private class FormatList(formats: List) : Filter.Group("Формат", formats) + private class StatList(status: List) : Filter.Group("Статус", status) + private class StatTranslateList(tstatus: List) : Filter.Group("Статус перевода", tstatus) + private class AgeList(ages: List) : Filter.Group("Возрастное ограничение", ages) + + private var genresList: List = listOf() + private var tagsList: List = listOf() + + private fun getTypeList() = listOf( + FilterersTri("Манга", "MANGA"), + FilterersTri("Манхва", "MANHWA"), + FilterersTri("Маньхуа", "MANHUA"), + FilterersTri("Комикс", "COMICS"), + FilterersTri("OEL Манга", "OEL_MANGA"), + FilterersTri("РуМанга", "RU_MANGA"), + ) + private fun getStatList() = listOf( + FilterersTri("Анонс", "ANNOUNCE"), + FilterersTri("Онгоинг", "ONGOING"), + FilterersTri("Выпущено", "FINISHED"), + FilterersTri("Приостановлено", "HIATUS"), + FilterersTri("Отменено", "CANCELLED"), + ) + + private fun getStatTranslateList() = listOf( + FilterersTri("Переводится", "IN_PROGRESS"), + FilterersTri("Завершён", "FINISHED"), + FilterersTri("Заморожен", "FROZEN"), + FilterersTri("Заброшен", "ABANDONED"), + ) + + private fun getAgeList() = listOf( + FilterersTri("0+", "GENERAL"), + FilterersTri("12+", "SENSITIVE"), + FilterersTri("16+", "QUESTIONABLE"), + FilterersTri("18+", "EXPLICIT"), + ) + private fun getFormatList() = listOf( + FilterersTri("Сборник", "DIGEST"), + FilterersTri("Додзинси", "DOUJINSHI"), + FilterersTri("В цвете", "IN_COLOR"), + FilterersTri("Сингл", "SINGLE"), + FilterersTri("Веб", "WEB"), + FilterersTri("Вебтун", "WEBTOON"), + FilterersTri("Ёнкома", "YONKOMA"), + FilterersTri("Short", "SHORT"), + ) + + companion object { + private const val offsetCount = 20 + private const val API_URL = "https://api.senkuro.com/graphql" + private val senkuroExcludeGenres = listOf("hentai", "yaoi", "yuri", "shoujo_ai", "shounen_ai") + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaTypeOrNull() + } + + private val json: Json by injectLazy() +} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/SenkuroGenerator.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/SenkuroGenerator.kt new file mode 100644 index 000000000..b95c5b5c0 --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/SenkuroGenerator.kt @@ -0,0 +1,25 @@ +package eu.kanade.tachiyomi.multisrc.senkuro + +import generator.ThemeSourceData.SingleLang +import generator.ThemeSourceGenerator + +class SenkuroGenerator : ThemeSourceGenerator { + + override val themePkg = "senkuro" + + override val themeClass = "Senkuro" + + override val baseVersionCode = 1 + + override val sources = listOf( + SingleLang("Senkuro", "https://senkuro.com", "ru", overrideVersionCode = 0), + SingleLang("Senkognito", "https://senkognito.com", "ru", isNsfw = true, overrideVersionCode = 0), + ) + + companion object { + @JvmStatic + fun main(args: Array) { + SenkuroGenerator().createAll() + } + } +} diff --git a/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/SenkuroQueries.kt b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/SenkuroQueries.kt new file mode 100644 index 000000000..0f868d53e --- /dev/null +++ b/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc/senkuro/SenkuroQueries.kt @@ -0,0 +1,244 @@ +package eu.kanade.tachiyomi.multisrc.senkuro + +import kotlinx.serialization.Serializable + +@Serializable +data class GraphQL( + val query: String, + val variables: T, +) + +private fun buildQuery(queryAction: () -> String): String { + return queryAction() + .trimIndent() + .replace("%", "$") +} + +@Serializable +data class SearchVariables( + val query: String? = null, + val type: FiltersDto? = null, + val status: FiltersDto? = null, + val translationStatus: FiltersDto? = null, + val genre: FiltersDto? = null, + val tag: FiltersDto? = null, + val format: FiltersDto? = null, + val rating: FiltersDto? = null, + val offset: Int? = null, +) { + @Serializable + data class FiltersDto( + val include: List? = null, + val exclude: List? = null, + ) +} + +val SEARCH_QUERY: String = buildQuery { + """ + query searchTachiyomiManga( + %query: String, + %type: MangaTachiyomiSearchTypeFilter, + %status: MangaTachiyomiSearchStatusFilter, + %translationStatus: MangaTachiyomiSearchTranslationStatusFilter, + %genre: MangaTachiyomiSearchGenreFilter, + %tag: MangaTachiyomiSearchTagFilter, + %format: MangaTachiyomiSearchGenreFilter, + %rating: MangaTachiyomiSearchTagFilter, + %offset: Int, + ) { + mangaTachiyomiSearch( + query:%query, + type: %type, + status: %status, + translationStatus: %translationStatus, + genre: %genre, + tag: %tag, + format: %format, + rating: %rating, + offset: %offset, + ) { + mangas { + id + slug + originalName { + lang + content + } + titles { + lang + content + } + alternativeNames { + lang + content + } + cover { + original { + url + } + } + } + } + } + """ +} + +@Serializable +data class FetchDetailsVariables( + val mangaId: String? = null, +) + +val DETAILS_QUERY: String = buildQuery { + """ + query fetchTachiyomiManga(%mangaId: ID!) { + mangaTachiyomiInfo(mangaId: %mangaId) { + id + slug + originalName { + lang + content + } + titles { + lang + content + } + alternativeNames { + lang + content + } + localizations { + lang + description + } + type + rating + status + formats + genres { + slug + titles { + lang + content + } + } + tags { + slug + titles { + lang + content + } + } + translationStatus + cover { + original { + url + } + } + mainStaff { + roles + person { + name + } + } + } + } + """ +} + +val CHAPTERS_QUERY: String = buildQuery { + """ + query fetchTachiyomiChapters(%mangaId: ID!) { + mangaTachiyomiChapters(mangaId: %mangaId) { + message + chapters { + id + slug + branchId + name + teamIds + number + volume + updatedAt + } + teams { + id + slug + name + } + } + } + + """ +} + +@Serializable +data class FetchChapterPagesVariables( + val mangaId: String? = null, + val chapterId: String? = null, +) + +val CHAPTERS_PAGES_QUERY: String = buildQuery { + """ + query fetchTachiyomiChapterPages( + %mangaId: ID!, + %chapterId: ID! + ) { + mangaTachiyomiChapterPages( + mangaId: %mangaId, + chapterId: %chapterId + ) { + pages { + url + } + } + } + """ +} + +@Serializable +data class MangaTachiyomiSearchFilters( + val mangaTachiyomiSearchFilters: FilterDto, +) { + @Serializable + data class FilterDto( + val genres: List, + val tags: List, + ) { + @Serializable + data class FilterDataDto( + val slug: String, + val titles: List, + ) { + @Serializable + data class TitleDto( + val lang: String, + val content: String, + ) + } + } +} + +val FILTERS_QUERY: String = buildQuery { + """ + query fetchTachiyomiSearchFilters { + mangaTachiyomiSearchFilters { + genres { + id + slug + titles { + lang + content + } + } + tags { + id + slug + titles { + lang + content + } + } + } + } + """ +}