diff --git a/lib/bangumi-scraper/build.gradle.kts b/lib/bangumi-scraper/build.gradle.kts new file mode 100644 index 0000000000..c49026e416 --- /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 0000000000..1f130ad25f --- /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 0000000000..3653314e06 --- /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 0000000000..08d1e30835 --- /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/zh/anime1/build.gradle b/src/zh/anime1/build.gradle new file mode 100644 index 0000000000..96c45c2dd5 --- /dev/null +++ b/src/zh/anime1/build.gradle @@ -0,0 +1,13 @@ +ext { + extName = 'Anime1.me' + extClass = '.Anime1' + extVersionCode = 3 +} + +apply from: "$rootDir/common.gradle" + +dependencies { + implementation(project(":lib:bangumi-scraper")) + //noinspection UseTomlInstead + implementation "com.github.houbb:opencc4j:1.8.1" +} diff --git a/src/zh/anime1/res/mipmap-hdpi/ic_launcher.png b/src/zh/anime1/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..985857355d Binary files /dev/null and b/src/zh/anime1/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/zh/anime1/res/mipmap-mdpi/ic_launcher.png b/src/zh/anime1/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..186328d501 Binary files /dev/null and b/src/zh/anime1/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/zh/anime1/res/mipmap-xhdpi/ic_launcher.png b/src/zh/anime1/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..195d9a2792 Binary files /dev/null and b/src/zh/anime1/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/zh/anime1/res/mipmap-xxhdpi/ic_launcher.png b/src/zh/anime1/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..599affaf33 Binary files /dev/null and b/src/zh/anime1/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/zh/anime1/res/mipmap-xxxhdpi/ic_launcher.png b/src/zh/anime1/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..20dda2b2a1 Binary files /dev/null and b/src/zh/anime1/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/zh/anime1/src/eu/kanade/tachiyomi/animeextension/zh/anime1/Anime1.kt b/src/zh/anime1/src/eu/kanade/tachiyomi/animeextension/zh/anime1/Anime1.kt new file mode 100644 index 0000000000..5901449267 --- /dev/null +++ b/src/zh/anime1/src/eu/kanade/tachiyomi/animeextension/zh/anime1/Anime1.kt @@ -0,0 +1,272 @@ +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 +import eu.kanade.tachiyomi.util.asJsoup +import eu.kanade.tachiyomi.util.parseAs +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.Request +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(), ConfigurableAnimeSource { + override val baseUrl: String + get() = "https://anime1.me" + override val lang: String + get() = "zh-hant" + override val name: String + get() = "Anime1.me" + override val supportsLatest: Boolean + get() = true + + override fun headersBuilder() = super.headersBuilder().add("referer", "$baseUrl/") + + private val videoApiUrl = "https://v.anime1.me/api" + private val dataUrl = "https://d1zquzjgwo9yb.cloudfront.net" + private val uploadDateFormat: SimpleDateFormat by lazy { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()) + } + private lateinit var data: JsonArray + private val cookieManager + get() = CookieManager.getInstance() + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + override fun animeDetailsParse(response: Response) = throw UnsupportedOperationException() + + override suspend fun getAnimeDetails(anime: SAnime): SAnime { + return if (bangumiEnable) { + BangumiScraper.fetchDetail( + client, + ZhTwConverterUtil.toSimple(anime.title.removeSuffixMark()), + fetchType = bangumiFetchType, + ) + } else { + anime.thumbnail_url = FIX_COVER + anime + } + } + + override fun episodeListParse(response: Response): List { + var document: Document? = response.asJsoup() + val episodes = mutableListOf() + val requestUrl = response.request.url.toString() + while (document != null) { + val items = document.select("article.post").map { + SEpisode.create().apply { + name = it.select(".entry-title").text() + val url = it.selectFirst(".entry-title a")?.attr("href") ?: requestUrl + setUrlWithoutDomain(url) + date_upload = it.select("time.updated").attr("datetime").let { date -> + runCatching { uploadDateFormat.parse(date)?.time }.getOrNull() ?: 0L + } + } + } + episodes.addAll(items) + val previousUrl = document.select(".nav-previous a").attr("href") + document = if (previousUrl.isBlank()) { + null + } else { + client.newCall(GET(previousUrl)).execute().asJsoup() + } + } + return episodes + } + + override fun videoListParse(response: Response): List