Merge branch 'refs/heads/main' into aniplay

This commit is contained in:
Josef František Straka 2024-11-21 23:30:03 +01:00
commit 66a163541e
16 changed files with 687 additions and 4 deletions

View file

@ -0,0 +1,7 @@
plugins {
id("lib-android")
}
dependencies {
compileOnly(libs.aniyomi.lib)
}

View file

@ -0,0 +1,83 @@
@file:UseSerializers(BoxItemSerializer::class)
package eu.kanade.tachiyomi.lib.bangumiscraper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Serializer
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@Serializable
internal data class Images(
val large: String,
val common: String,
val medium: String,
val small: String,
)
@Serializable
internal data class BoxItem(
val key: String,
val value: String,
)
@OptIn(ExperimentalSerializationApi::class)
@Serializer(forClass = BoxItem::class)
internal object BoxItemSerializer : KSerializer<BoxItem> {
override fun deserialize(decoder: Decoder): BoxItem {
val item = (decoder as JsonDecoder).decodeJsonElement().jsonObject
val key = item["key"]!!.jsonPrimitive.content
val value = (item["value"] as? JsonPrimitive)?.contentOrNull ?: ""
return BoxItem(key, value)
}
}
@Serializable
internal data class Subject(
val name: String,
@SerialName("name_cn")
val nameCN: String,
val summary: String,
val images: Images,
@SerialName("meta_tags")
val metaTags: List<String>,
@SerialName("infobox")
val infoBox: List<BoxItem>,
) {
fun findAuthor(): String? {
return findInfo("导演", "原作")
}
fun findArtist(): String? {
return findInfo("美术监督", "总作画监督", "动画制作")
}
fun findInfo(vararg keys: String): String? {
keys.forEach { key ->
return infoBox.find { item ->
item.key == key
}?.value ?: return@forEach
}
return null
}
}
@Serializable
internal data class SearchItem(
val id: Int,
val name: String,
@SerialName("name_cn")
val nameCN: String,
val summary: String,
val images: Images,
)
@Serializable
internal data class SearchResponse(val results: Int, val list: List<SearchItem>)

View file

@ -0,0 +1,126 @@
package eu.kanade.tachiyomi.lib.bangumiscraper
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
enum class BangumiSubjectType(val value: Int) {
BOOK(1),
ANIME(2),
MUSIC(3),
GAME(4),
REAL(6),
}
enum class BangumiFetchType {
/**
* Give cover and summary info.
*/
SHORT,
/**
* Give all require info include genre and author info.
*/
ALL,
}
/**
* A helper class to fetch anime details from Bangumi
*/
object BangumiScraper {
private const val SEARCH_URL = "https://api.bgm.tv/search/subject"
private const val SUBJECTS_URL = "https://api.bgm.tv/v0/subjects"
/**
* Fetch anime details info from Bangumi
* @param fetchType check [BangumiFetchType] to get detail
* @param subjectType check [BangumiSubjectType] to get detail
* @param requestProducer used to custom request
*/
suspend fun fetchDetail(
client: OkHttpClient,
keyword: String,
fetchType: BangumiFetchType = BangumiFetchType.SHORT,
subjectType: BangumiSubjectType = BangumiSubjectType.ANIME,
requestProducer: (url: HttpUrl) -> Request = { url -> GET(url) },
): SAnime {
val httpUrl = SEARCH_URL.toHttpUrl().newBuilder()
.addPathSegment(keyword)
.addQueryParameter(
"responseGroup",
if (fetchType == BangumiFetchType.ALL) {
"small"
} else {
"medium"
},
)
.addQueryParameter("type", "${subjectType.value}")
.addQueryParameter("start", "0")
.addQueryParameter("max_results", "1")
.build()
val searchResponse = client.newCall(requestProducer(httpUrl)).awaitSuccess()
.checkErrorMessage().parseAs<SearchResponse>()
return if (searchResponse.list.isEmpty()) {
SAnime.create()
} else {
val item = searchResponse.list[0]
if (fetchType == BangumiFetchType.ALL) {
fetchSubject(client, "${item.id}", requestProducer)
} else {
SAnime.create().apply {
thumbnail_url = item.images.large
description = item.summary
}
}
}
}
private suspend fun fetchSubject(
client: OkHttpClient,
id: String,
requestProducer: (url: HttpUrl) -> Request,
): SAnime {
val httpUrl = SUBJECTS_URL.toHttpUrl().newBuilder().addPathSegment(id).build()
val subject = client.newCall(requestProducer(httpUrl)).awaitSuccess()
.checkErrorMessage().parseAs<Subject>()
return SAnime.create().apply {
thumbnail_url = subject.images.large
description = subject.summary
genre = buildList {
addAll(subject.metaTags)
subject.findInfo("动画制作")?.let { add(it) }
subject.findInfo("放送开始")?.let { add(it) }
}.joinToString()
author = subject.findAuthor()
artist = subject.findArtist()
if (subject.findInfo("播放结束") != null) {
status = SAnime.COMPLETED
} else if (subject.findInfo("放送开始") != null) {
status = SAnime.ONGOING
}
}
}
private fun Response.checkErrorMessage(): String {
val responseStr = body.string()
val errorMessage =
responseStr.parseAs<JsonElement>().jsonObject["error"]?.jsonPrimitive?.contentOrNull
if (errorMessage != null) {
throw BangumiScraperException(errorMessage)
}
return responseStr
}
}

View file

@ -0,0 +1,3 @@
package eu.kanade.tachiyomi.lib.bangumiscraper
class BangumiScraperException(message: String) : Exception(message)

View file

@ -0,0 +1,8 @@
ext {
extName = 'Jable'
extClass = '.JableFactory'
extVersionCode = 1
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,248 @@
package eu.kanade.tachiyomi.animeextension.all.jable
import android.app.Application
import android.content.SharedPreferences
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
import eu.kanade.tachiyomi.animesource.model.AnimeUpdateStrategy
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.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
class Jable(override val lang: String) : AnimeHttpSource() {
override val baseUrl: String
get() = "https://jable.tv"
override val name: String
get() = "Jable"
override val supportsLatest: Boolean
get() = true
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val json by injectLazy<Json>()
private var tagsUpdated = false
override fun animeDetailsRequest(anime: SAnime): Request {
return GET("$baseUrl${anime.url}?lang=$lang", headers)
}
override fun animeDetailsParse(response: Response): SAnime {
val doc = response.asJsoup()
return SAnime.create().apply {
val info = doc.select(".info-header")
title = info.select(".header-left h4").text()
author = info.select(".header-left .model")
.joinToString { it.select("span[title]").attr("title") }
genre = doc.select(".tags a").joinToString { it.text() }
update_strategy = AnimeUpdateStrategy.ONLY_FETCH_ONCE
status = SAnime.COMPLETED
description = info.select(".header-right").text()
}
}
override fun episodeListParse(response: Response) = throw UnsupportedOperationException()
override suspend fun getEpisodeList(anime: SAnime): List<SEpisode> {
return listOf(
SEpisode.create().apply {
name = "Episode"
url = anime.url
},
)
}
override fun videoListParse(response: Response): List<Video> {
val doc = response.asJsoup()
val videoUrl = doc.selectFirst("script:containsData(var hlsUrl)")!!.data()
.substringAfter("var hlsUrl = '").substringBefore("'")
return listOf(Video(videoUrl, "Default", videoUrl))
}
override fun latestUpdatesParse(response: Response): AnimesPage {
val doc = response.asJsoup()
if (!tagsUpdated) {
tagsUpdated = preferences.saveTags(
doc.select("a.tag").associate {
it.ownText() to it.attr("href").substringAfter(baseUrl).removePrefix("/")
.removeSuffix("/")
},
)
}
return AnimesPage(
doc.select(".container .video-img-box").map {
SAnime.create().apply {
setUrlWithoutDomain(it.select(".img-box a").attr("href"))
thumbnail_url = it.select(".img-box img").attr("data-src")
title = it.select(".detail .title").text()
}
},
doc.select(".container .pagination .page-item .page-link.disabled").isNullOrEmpty(),
)
}
override fun latestUpdatesRequest(page: Int) =
searchRequest("latest-updates", page, latestFilter)
override fun popularAnimeParse(response: Response): AnimesPage = latestUpdatesParse(response)
override fun popularAnimeRequest(page: Int) =
searchRequest("hot", page, popularFilter)
override fun searchAnimeParse(response: Response) = latestUpdatesParse(response)
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
return if (query.isNotEmpty()) {
searchRequest(
"search/$query",
page,
AnimeFilterList(filters.list + defaultSearchFunctionFilter),
query = query,
)
} else {
val path = filters.list.filterIsInstance<TagFilter>()
.firstOrNull()?.selected?.second?.takeUnless { it.isEmpty() } ?: "hot"
searchRequest(path, page, AnimeFilterList(filters.list + commonVideoListFuncFilter))
}
}
private fun searchRequest(
path: String,
page: Int,
filters: AnimeFilterList = AnimeFilterList(),
query: String = "",
): Request {
val urlBuilder = baseUrl.toHttpUrl().newBuilder()
.addPathSegments("$path/")
.addQueryParameter("lang", lang)
if (tagsUpdated) {
// load whole page for update filter tags info
urlBuilder.addQueryParameter("mode", "async")
}
filters.list.forEach {
when (it) {
is BlockFunctionFilter -> {
urlBuilder.addQueryParameter("function", it.selected.functionName)
.addQueryParameter("block_id", it.selected.blockId)
}
is SortFilter -> {
if (it.selected.second.isNotEmpty()) {
urlBuilder.addQueryParameter("sort_by", it.selected.second)
}
}
else -> {}
}
}
if (query.isNotEmpty()) {
urlBuilder.addQueryParameter("q", query)
}
urlBuilder.addQueryParameter("from", "%02d".format(page))
.addQueryParameter("_", System.currentTimeMillis().toString())
return GET(urlBuilder.build())
}
override fun getFilterList(): AnimeFilterList {
return AnimeFilterList(
SortFilter(
intl.filterPopularSortTitle,
arrayOf(
"" to "",
intl.hotMonth to "video_viewed_month",
intl.hotWeek to "video_viewed_week",
intl.hotDay to "video_viewed_today",
intl.hotAll to "video_viewed",
),
),
TagFilter(
intl.filterTagTitle,
buildList {
add("" to "")
preferences.getTags()?.forEach {
add(it.key to it.value)
}
}.toTypedArray(),
),
SortFilter(
intl.filterTagsSortTitle,
arrayOf(
"" to "",
intl.sortLatestUpdate to "post_date",
intl.sortMostView to "video_viewed",
intl.sortMostFavorite to "most_favourited",
intl.sortRecentBest to "post_date_and_popularity",
),
),
)
}
private fun SharedPreferences.getTags(): Map<String, String>? {
val savedStr = getString("${lang}_$PREF_KEY_TAGS", null)
if (savedStr.isNullOrEmpty()) {
return null
}
return json.decodeFromString<Map<String, String>>(savedStr)
}
private fun SharedPreferences.saveTags(tags: Map<String, String>): Boolean {
if (tags.isNotEmpty()) {
edit().putString("${lang}_$PREF_KEY_TAGS", json.encodeToString(tags)).apply()
return true
}
return false
}
private val intl by lazy {
JableIntl(lang)
}
private val commonVideoListFuncFilter by lazy {
BlockFunctionFilter(
intl.popular,
arrayOf(BlockFunction(intl.popular, "list_videos_common_videos_list")),
)
}
private val defaultSearchFunctionFilter by lazy {
BlockFunctionFilter("", arrayOf(BlockFunction("", "list_videos_videos_list_search_result")))
}
private val popularFilter by lazy {
AnimeFilterList(
commonVideoListFuncFilter,
SortFilter(
intl.hotWeek,
arrayOf(intl.hotWeek to "video_viewed_week"),
),
)
}
private val latestFilter by lazy {
AnimeFilterList(
BlockFunctionFilter(
intl.latestUpdate,
arrayOf(BlockFunction(intl.latestUpdate, "list_videos_latest_videos_list")),
),
SortFilter(
intl.sortLatestUpdate,
arrayOf(intl.sortLatestUpdate to "post_date"),
),
)
}
companion object {
const val PREF_KEY_TAGS = "pref_key_tags"
}
}

View file

@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.animeextension.all.jable
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.animesource.AnimeSourceFactory
class JableFactory : AnimeSourceFactory {
override fun createSources(): List<AnimeSource> {
return listOf(
Jable("zh"),
Jable("en"),
Jable("jp"),
)
}
}

View file

@ -0,0 +1,25 @@
package eu.kanade.tachiyomi.animeextension.all.jable
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
data class BlockFunction(
val name: String,
val blockId: String,
val functionName: String = "get_block",
)
class BlockFunctionFilter(name: String, private val functions: Array<BlockFunction>) :
AnimeFilter.Select<String>(name, functions.map { it.name }.toTypedArray()) {
val selected
get() = functions[state]
}
open class UriPartFilter(name: String, private val pairs: Array<Pair<String, String>>) :
AnimeFilter.Select<String>(name, pairs.map { it.first }.toTypedArray()) {
val selected
get() = pairs[state]
}
class SortFilter(name: String, pairs: Array<Pair<String, String>>) : UriPartFilter(name, pairs)
class TagFilter(name: String, pairs: Array<Pair<String, String>>) : UriPartFilter(name, pairs)

View file

@ -0,0 +1,76 @@
package eu.kanade.tachiyomi.animeextension.all.jable
internal interface Intl {
val popular: String
val latestUpdate: String
val sortLatestUpdate: String
val sortMostView: String
val sortMostFavorite: String
val sortRecentBest: String
val hotDay: String
val hotWeek: String
val hotMonth: String
val hotAll: String
val filterPopularSortTitle: String
val filterTagsSortTitle: String
val filterTagTitle: String
}
internal class JableIntl private constructor(delegate: Intl) : Intl by delegate {
constructor(lang: String) : this(
when (lang) {
"zh" -> ZH()
"jp" -> JP()
"en" -> EN()
else -> ZH()
},
)
}
internal class ZH : Intl {
override val popular: String = "熱度優先"
override val latestUpdate: String = "新片優先"
override val sortLatestUpdate: String = "最近更新"
override val sortMostView: String = "最多觀看"
override val sortMostFavorite: String = "最高收藏"
override val sortRecentBest: String = "近期最佳"
override val hotDay: String = "今日熱門"
override val hotWeek: String = "本周熱門"
override val hotMonth: String = "本月熱門"
override val hotAll: String = "所有時間"
override val filterPopularSortTitle: String = "熱門排序"
override val filterTagsSortTitle: String = "通用排序"
override val filterTagTitle: String = "標籤"
}
internal class JP : Intl {
override val popular: String = "人気優先"
override val latestUpdate: String = "新作優先"
override val sortLatestUpdate: String = "最近更新"
override val sortMostView: String = "最も見ら"
override val sortMostFavorite: String = "最もお気に入"
override val sortRecentBest: String = "最近ベスト"
override val hotDay: String = "今日のヒット"
override val hotWeek: String = "今週のヒット"
override val hotMonth: String = "今月のヒット"
override val hotAll: String = "全ての時間"
override val filterPopularSortTitle: String = "人気ソート"
override val filterTagsSortTitle: String = "一般ソート"
override val filterTagTitle: String = "タグ"
}
internal class EN : Intl {
override val popular: String = "Hot"
override val latestUpdate: String = "Newest"
override val sortLatestUpdate: String = "Recent Update"
override val sortMostView: String = "Most Viewed"
override val sortMostFavorite: String = "Most Favorite"
override val sortRecentBest: String = "Best Recently"
override val hotDay: String = "Today"
override val hotWeek: String = "This Week"
override val hotMonth: String = "This Month"
override val hotAll: String = "All Time"
override val filterPopularSortTitle: String = "Popular Sorting"
override val filterTagsSortTitle: String = "General Sorting"
override val filterTagTitle: String = "Tag"
}

View file

@ -1,7 +1,13 @@
ext {
extName = 'Anime1.me'
extClass = '.Anime1'
extVersionCode = 1
extVersionCode = 3
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib:bangumi-scraper"))
//noinspection UseTomlInstead
implementation "com.github.houbb:opencc4j:1.8.1"
}

View file

@ -1,12 +1,21 @@
package eu.kanade.tachiyomi.animeextension.zh.anime1
import android.app.Application
import android.content.SharedPreferences
import android.webkit.CookieManager
import androidx.preference.CheckBoxPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import com.github.houbb.opencc4j.util.ZhTwConverterUtil
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
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.lib.bangumiscraper.BangumiFetchType
import eu.kanade.tachiyomi.lib.bangumiscraper.BangumiScraper
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
@ -24,10 +33,12 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
class Anime1 : AnimeHttpSource() {
class Anime1 : AnimeHttpSource(), ConfigurableAnimeSource {
override val baseUrl: String
get() = "https://anime1.me"
override val lang: String
@ -47,11 +58,22 @@ class Anime1 : AnimeHttpSource() {
private lateinit var data: JsonArray
private val cookieManager
get() = CookieManager.getInstance()
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun animeDetailsParse(response: Response) = throw UnsupportedOperationException()
override suspend fun getAnimeDetails(anime: SAnime): SAnime {
return SAnime.create().apply {
thumbnail_url = FIX_COVER
return if (bangumiEnable) {
BangumiScraper.fetchDetail(
client,
ZhTwConverterUtil.toSimple(anime.title.removeSuffixMark()),
fetchType = bangumiFetchType,
)
} else {
anime.thumbnail_url = FIX_COVER
anime
}
}
@ -168,13 +190,78 @@ class Anime1 : AnimeHttpSource() {
return GET(url.build())
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val bangumiScraper = CheckBoxPreference(screen.context).apply {
key = PREF_KEY_BANGUMI
title = "啟用Bangumi刮削"
}
val bangumiFetchType = ListPreference(screen.context).apply {
key = PREF_KEY_BANGUMI_FETCH_TYPE
title = "詳情拉取設置"
setVisible(bangumiEnable)
entries = arrayOf("拉取部分數據", "拉取完整數據")
entryValues = arrayOf(BangumiFetchType.SHORT.name, BangumiFetchType.ALL.name)
setDefaultValue(entryValues[0])
summary = when (bangumiFetchType) {
BangumiFetchType.SHORT -> entries[0]
BangumiFetchType.ALL -> entries[1]
else -> entries[0]
}
setOnPreferenceChangeListener { _, value ->
summary = when (value) {
BangumiFetchType.SHORT.name -> entries[0]
BangumiFetchType.ALL.name -> entries[1]
else -> entries[0]
}
true
}
}
bangumiScraper.setOnPreferenceChangeListener { _, value ->
bangumiFetchType.setVisible(value as Boolean)
true
}
screen.apply {
addPreference(bangumiScraper)
addPreference(bangumiFetchType)
}
}
private val bangumiEnable: Boolean
get() = preferences.getBoolean(PREF_KEY_BANGUMI, false)
private val bangumiFetchType: BangumiFetchType
get() {
val fetchTypeName =
preferences.getString(PREF_KEY_BANGUMI_FETCH_TYPE, BangumiFetchType.SHORT.name)
return when (fetchTypeName) {
BangumiFetchType.SHORT.name -> BangumiFetchType.SHORT
BangumiFetchType.ALL.name -> BangumiFetchType.ALL
else -> BangumiFetchType.SHORT
}
}
private fun JsonArray.getContent(index: Int): String? {
return getOrNull(index)?.jsonPrimitive?.contentOrNull
}
private fun String.removeSuffixMark(): String {
return removeBracket("(", ")").removeBracket("[", "]").trim()
}
private fun String.removeBracket(start: String, end: String): String {
val seasonStart = indexOf(start)
val seasonEnd = indexOf(end)
if (seasonEnd > seasonStart) {
return removeRange(seasonStart, seasonEnd + 1)
}
return this
}
companion object {
const val PAGE_SIZE = 20
const val FIX_COVER = "https://sta.anicdn.com/playerImg/8.jpg"
const val PREF_KEY_BANGUMI = "PREF_KEY_BANGUMI"
const val PREF_KEY_BANGUMI_FETCH_TYPE = "PREF_KEY_BANGUMI_FETCH_TYPE"
}
}