Add Xiaoxintv(小宝影院) (#341)

This commit is contained in:
AlphaBoom 2024-10-31 19:41:12 +08:00 committed by GitHub
parent e780630225
commit 21bc5b8bfb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 506 additions and 0 deletions

View file

@ -0,0 +1,7 @@
ext {
extName = 'Xiaoxintv'
extClass = '.Xiaoxintv'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,221 @@
@file:Suppress("LocalVariableName", "PropertyName")
package eu.kanade.tachiyomi.animeextension.zh.xiaoxintv
import android.content.SharedPreferences
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
private const val PREF_KEY_FILTER_CONFIG_PREFIX = "STORED_SEARCH_CONFIG"
open class PathFilter(name: String, private val beans: Array<out SearchBean>) :
AnimeFilter.Select<String>(name, beans.map { it.name }.toTypedArray()) {
val selected
get() = beans[state]
}
class GroupFilter(name: String, filters: List<PathFilter>) :
AnimeFilter.Group<PathFilter>(name, filters)
internal enum class FilterType(val title: String) {
TYPE("类型"),
CLASS("分类"),
YEAR("年份"),
LANG("语言"),
SORT("排序"),
REGION("地区"),
}
interface SearchBean {
val name: String
val ignore: Boolean
fun toPath(): String
}
@Serializable
data class SearchType(
override val name: String,
val id: String,
override val ignore: Boolean = false,
) : SearchBean {
constructor(name: String, id: Int) : this(name, "$id")
override fun toPath() = "/id/$id"
}
internal fun SearchType.toFilter(): PathFilter {
return PathFilter(name, arrayOf(this))
}
@Serializable
data class SearchSort(
override val name: String,
val by: String,
override val ignore: Boolean = false,
) : SearchBean {
override fun toPath() = "/by/$by"
}
@Serializable
data class SearchYear(override val name: String, override val ignore: Boolean = false) :
SearchBean {
override fun toPath() = "/year/$name"
}
@Serializable
data class SearchLang(override val name: String, override val ignore: Boolean = false) :
SearchBean {
override fun toPath() = "/lang/$name"
}
@Serializable
data class SearchClass(override val name: String, override val ignore: Boolean = false) :
SearchBean {
override fun toPath() = "/class/$name"
}
@Serializable
data class SearchRegion(override val name: String, override val ignore: Boolean = false) :
SearchBean {
override fun toPath() = "/area/$name"
}
@Serializable
data class SearchFilterConfig(
val type: List<SearchType>,
val category: List<SearchClass> = emptyList(),
val year: List<SearchYear> = emptyList(),
val lang: List<SearchLang> = emptyList(),
val region: List<SearchRegion> = emptyList(),
) {
fun isEmpty() =
type.isEmpty() && category.isEmpty() && year.isEmpty() && lang.isEmpty() && region.isEmpty()
}
private inline fun <reified T> c(): Class<T> {
return T::class.java
}
private val searchPriority = arrayOf(
c<SearchRegion>(),
c<SearchSort>(),
c<SearchClass>(),
c<SearchType>(),
c<SearchLang>(),
c<SearchYear>(),
)
internal fun Iterable<SearchBean>.toPath(): String {
return this.asSequence().filterNot { it.ignore }
.groupBy { it::class.java }.flatMap { it.value.subList(it.value.size - 1, it.value.size) }
.sortedBy {
searchPriority.indexOf(it::class.java)
}
.joinToString(separator = "") { it.toPath() }.removePrefix("/")
}
private val defaultLangList =
listOf(
SearchLang("全部", ignore = true),
SearchLang("国语"),
SearchLang("粤语"),
SearchLang("英语"),
SearchLang("其他"),
)
private val typeAll = SearchType("全部", "-1", ignore = true)
private val categoryAll = SearchClass("全部", ignore = true)
private val yearAll = SearchYear("全部", ignore = true)
private val regionAll = SearchRegion("全部", ignore = true)
private val defaultSearchFilterConfig = mapOf(
// anime
"5" to SearchFilterConfig(
type = listOf(typeAll, SearchType("国产动漫", 51), SearchType("日本动漫", 52)),
category = listOf(
categoryAll,
SearchClass("热血"),
SearchClass("格斗"),
SearchClass("其他"),
),
year = listOf(yearAll),
lang = defaultLangList,
),
// movie
"7" to SearchFilterConfig(
type = listOf(typeAll),
region = listOf(regionAll),
year = listOf(yearAll),
lang = defaultLangList,
),
// tv
"6" to SearchFilterConfig(
type = listOf(typeAll),
category = listOf(categoryAll),
year = listOf(yearAll),
lang = defaultLangList,
),
// variety show
"3" to SearchFilterConfig(
type = listOf(typeAll),
category = listOf(categoryAll),
year = listOf(yearAll),
lang = defaultLangList,
),
// documentary
"21" to SearchFilterConfig(
type = emptyList(),
region = listOf(regionAll),
year = listOf(yearAll),
lang = defaultLangList,
),
// short show
"64" to SearchFilterConfig(
type = listOf(typeAll),
),
)
private fun findDefaultSearchFilterConfig(majorTypeId: String): SearchFilterConfig {
return defaultSearchFilterConfig.getOrElse(majorTypeId) {
SearchFilterConfig(
listOf(typeAll),
)
}
}
private fun genFilterConfigKey(majorTypeId: String): String {
return PREF_KEY_FILTER_CONFIG_PREFIX + "_$majorTypeId"
}
internal val defaultMajorSearchTypeSet = arrayOf(
SearchType("动漫", 5),
SearchType("电影", 7),
SearchType("电视剧", 6),
SearchType("综艺", 3),
SearchType("纪录片", 21),
SearchType("短剧", 64),
)
internal val defaultSortTypeSet =
arrayOf(
SearchSort("时间", "time", ignore = true),
SearchSort("人气", "hits"),
SearchSort("评分", "score"),
)
fun SharedPreferences.findSearchFilterConfig(majorTypeId: String, json: Json): SearchFilterConfig {
// check shared preferences
return getString(genFilterConfigKey(majorTypeId), null)?.let { json.decodeFromString(it) }
?: findDefaultSearchFilterConfig(majorTypeId)
}
fun SharedPreferences.saveSearchFilterConfig(
majorTypeId: String,
searchFilterConfig: SearchFilterConfig,
json: Json,
) {
edit().putString(genFilterConfigKey(majorTypeId), json.encodeToString(searchFilterConfig))
.apply()
}

View file

@ -0,0 +1,278 @@
package eu.kanade.tachiyomi.animeextension.zh.xiaoxintv
import android.app.Application
import android.content.SharedPreferences
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimesPage
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
private object HotSortFilter :
PathFilter(FilterType.SORT.title, arrayOf(SearchSort("人气", "hits")))
class Xiaoxintv : AnimeHttpSource() {
override val baseUrl: String
get() = "https://xiaoxintv.cc"
override val lang: String
get() = "zh"
override val name: String
get() = "小宝影院"
override val supportsLatest: Boolean
get() = true
private val majorSearchTypeSet: Array<SearchType>
get() = defaultMajorSearchTypeSet
private val searchSortTypeSet: Array<SearchSort>
get() = defaultSortTypeSet
private val filterUpdateRecord by lazy {
majorSearchTypeSet.associateWith {
false
}.toMutableMap()
}
private val json by injectLazy<Json>()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun animeDetailsParse(response: Response): SAnime {
val document = response.asJsoup()
return SAnime.create().apply {
thumbnail_url =
document.select(".myui-vodlist__thumb.picture img").attr("data-original")
url = document.select(".myui-vodlist__thumb.picture").attr("href")
title = document.select(".myui-content__detail .title").text()
author = document.selectFirst("p.data:contains(主演:)")?.text()
artist = document.selectFirst("p.data:contains(导演:)")?.text()
description = document.selectFirst("p.data:contains(简介:)")?.ownText()
}
}
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
return document.select("#playlist1 ul li").mapIndexed { index, element ->
SEpisode.create().apply {
url = element.select("a").attr("href")
name = element.attr("title")
episode_number = index.toFloat()
}
}.reversed()
}
private fun findVideoUrl(document: Document): String {
val script = document.select("script:containsData(player_aaaa)").first()!!.data()
val info = script.substringAfter("player_aaaa=").let { json.parseToJsonElement(it) }
return info.jsonObject["url"]!!.jsonPrimitive.content
}
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
val videoUrl = findVideoUrl(document)
return listOf(Video(videoUrl, "小宝影院", videoUrl))
}
override fun latestUpdatesParse(response: Response): AnimesPage {
return searchFilterParse(response)
}
override fun latestUpdatesRequest(page: Int) = searchAnimeRequest(
page,
"",
AnimeFilterList(
majorSearchTypeSet[0].toFilter(),
),
)
override fun popularAnimeParse(response: Response): AnimesPage {
return searchFilterParse(response)
}
override fun popularAnimeRequest(page: Int) = searchAnimeRequest(
page,
"",
AnimeFilterList(
majorSearchTypeSet[0].toFilter(),
HotSortFilter,
),
)
override fun searchAnimeParse(response: Response): AnimesPage {
val requestUrl = response.request.url.toString()
if (requestUrl.contains("/vod/search")) {
return searchKeywordParse(response)
}
return searchFilterParse(response)
}
private fun searchFilterParse(response: Response): AnimesPage {
val document = response.asJsoup()
tryUpdateFilters(response.request, document)
val items = document.select(".myui-vodlist__box").map {
SAnime.create().apply {
val thumbNode = it.select(".myui-vodlist__thumb")
url = thumbNode.attr("href")
thumbnail_url = thumbNode.attr("data-original")
title = thumbNode.attr("title")
}
}
val nextPageUrl = document.select(".myui-page a:contains(下一页)").attr("href")
return AnimesPage(items, !response.request.url.toString().endsWith(nextPageUrl))
}
private fun tryUpdateFilters(request: Request, document: Document) {
val requestUrl = request.url.toString()
val match = filterUpdateRecord.firstNotNullOfOrNull {
if (requestUrl.endsWith(it.key.toPath())) {
it.key
} else {
null
}
}
if (match == null || filterUpdateRecord[match] == true) {
return
}
filterUpdateRecord[match] = true
var typeList: List<SearchType> = emptyList()
var langList: List<SearchLang> = emptyList()
var yearList: List<SearchYear> = emptyList()
var regionList: List<SearchRegion> = emptyList()
var classList: List<SearchClass> = emptyList()
document.select(".myui-panel_bd .myui-screen__list").forEach {
val li = it.select("li a")
val key = li[0].text()
val options = li.drop(1)
when (key) {
FilterType.TYPE.title -> {
typeList = options.mapIndexed { index, element ->
SearchType(
element.text(),
element.attr("href").substringAfter("id/")
.substringBefore("/").removeSuffix(".html"),
ignore = index == 0,
)
}
}
FilterType.LANG.title -> {
langList = options.mapIndexed { index, element ->
SearchLang(element.text(), ignore = index == 0)
}
}
FilterType.YEAR.title -> {
yearList = options.mapIndexed { index, element ->
SearchYear(element.text(), ignore = index == 0)
}
}
FilterType.REGION.title -> {
regionList = options.mapIndexed { index, element ->
SearchRegion(element.text(), ignore = index == 0)
}
}
FilterType.CLASS.title -> {
classList = options.mapIndexed { index, element ->
SearchClass(element.text(), ignore = index == 0)
}
}
else -> {}
}
}
val config = SearchFilterConfig(typeList, classList, yearList, langList, regionList)
if (config.isEmpty()) {
return
}
preferences.saveSearchFilterConfig(match.id, config, json)
}
private fun searchKeywordParse(response: Response): AnimesPage {
val document = response.asJsoup()
val items = document.select("#searchList li").map {
SAnime.create().apply {
val thumbNode = it.select("a.myui-vodlist__thumb")
url = thumbNode.attr("href")
thumbnail_url = thumbNode.attr("data-original")
title = thumbNode.attr("title")
}
}
val nextPageUrl = document.select(".myui-page a:contains(下一页)").attr("href")
return AnimesPage(items, !response.request.url.toString().endsWith(nextPageUrl))
}
private fun keywordQuery(page: Int, query: String): Request {
val searchUrl = baseUrl.toHttpUrl().newBuilder()
.addPathSegments("index.php/vod/search")
if (page > 1) {
searchUrl.addPathSegments("page/$page/wd/$query")
} else {
searchUrl.addQueryParameter("wd", query)
}
return GET(searchUrl.build())
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
if (query.isNotBlank()) {
return keywordQuery(page, query)
}
val searchUrl = baseUrl.toHttpUrl().newBuilder().addPathSegments("index.php/vod/show")
val filterPath = filters.flatMap {
if (it is GroupFilter) {
it.state
} else {
listOf(it)
}
}.filterIsInstance<PathFilter>().map { it.selected }.toPath()
if (filterPath.isEmpty()) {
searchUrl.addPathSegments(majorSearchTypeSet[0].toPath().removePrefix("/"))
} else {
searchUrl.addPathSegments(filterPath)
}
if (page > 1) {
searchUrl.addPathSegments("page/$page")
}
return GET(searchUrl.build())
}
private fun List<SearchBean>.toFilter(name: String): PathFilter? {
if (isEmpty()) {
return null
}
return PathFilter(name, toTypedArray())
}
override fun getFilterList(): AnimeFilterList {
val groupFilters = majorSearchTypeSet.map { majorType ->
val config = preferences.findSearchFilterConfig(majorType.id, json)
val filters = listOfNotNull(
config.type.toFilter(FilterType.TYPE.title),
config.region.toFilter(FilterType.REGION.title),
config.category.toFilter(FilterType.CLASS.title),
config.year.toFilter(FilterType.YEAR.title),
config.lang.toFilter(FilterType.LANG.title),
)
GroupFilter(majorType.name, filters)
}.toTypedArray()
return AnimeFilterList(
PathFilter("主分类", majorSearchTypeSet),
PathFilter(FilterType.SORT.title, searchSortTypeSet),
AnimeFilter.Header("展开下方与主分类对应分组可进行更多设置"),
*groupFilters,
)
}
}