diff --git a/lib/bangumi-scraper/build.gradle.kts b/lib/bangumi-scraper/build.gradle.kts new file mode 100644 index 00000000..c49026e4 --- /dev/null +++ b/lib/bangumi-scraper/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("lib-android") +} + +dependencies { + compileOnly(libs.aniyomi.lib) +} \ No newline at end of file diff --git a/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiDTO.kt b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiDTO.kt new file mode 100644 index 00000000..1f130ad2 --- /dev/null +++ b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiDTO.kt @@ -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 { + 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, + @SerialName("infobox") + val infoBox: List, +) { + 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) diff --git a/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraper.kt b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraper.kt new file mode 100644 index 00000000..3653314e --- /dev/null +++ b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraper.kt @@ -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() + 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() + 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().jsonObject["error"]?.jsonPrimitive?.contentOrNull + if (errorMessage != null) { + throw BangumiScraperException(errorMessage) + } + return responseStr + } +} + + + diff --git a/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraperException.kt b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraperException.kt new file mode 100644 index 00000000..08d1e308 --- /dev/null +++ b/lib/bangumi-scraper/src/main/java/eu/kanade/tachiyomi/lib/bangumiscraper/BangumiScraperException.kt @@ -0,0 +1,3 @@ +package eu.kanade.tachiyomi.lib.bangumiscraper + +class BangumiScraperException(message: String) : Exception(message) diff --git a/src/all/jable/build.gradle b/src/all/jable/build.gradle new file mode 100644 index 00000000..f9304496 --- /dev/null +++ b/src/all/jable/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Jable' + extClass = '.JableFactory' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/jable/res/mipmap-hdpi/ic_launcher.png b/src/all/jable/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 00000000..dc1e7326 Binary files /dev/null and b/src/all/jable/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/jable/res/mipmap-mdpi/ic_launcher.png b/src/all/jable/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 00000000..6d68c4e1 Binary files /dev/null and b/src/all/jable/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/jable/res/mipmap-xhdpi/ic_launcher.png b/src/all/jable/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 00000000..f46a5539 Binary files /dev/null and b/src/all/jable/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/jable/res/mipmap-xxhdpi/ic_launcher.png b/src/all/jable/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 00000000..78f0ad3b Binary files /dev/null and b/src/all/jable/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/jable/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/jable/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 00000000..5b55e575 Binary files /dev/null and b/src/all/jable/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/jable/src/eu/kanade/tachiyomi/animeextension/all/jable/Jable.kt b/src/all/jable/src/eu/kanade/tachiyomi/animeextension/all/jable/Jable.kt new file mode 100644 index 00000000..d8b3bc20 --- /dev/null +++ b/src/all/jable/src/eu/kanade/tachiyomi/animeextension/all/jable/Jable.kt @@ -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().getSharedPreferences("source_$id", 0x0000) + } + private val json by injectLazy() + 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 { + return listOf( + SEpisode.create().apply { + name = "Episode" + url = anime.url + }, + ) + } + + override fun videoListParse(response: Response): List