diff --git a/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt b/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt index 820bedacb2..2217b0bb31 100644 --- a/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt +++ b/app/shared/app-data/src/androidMain/kotlin/data/source/media/resolver/AndroidWebVideoSourceResolver.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor.Instruction import me.him188.ani.app.platform.LocalContext import me.him188.ani.app.videoplayer.HttpStreamingVideoSource import me.him188.ani.app.videoplayer.data.VideoSource @@ -30,10 +31,13 @@ import me.him188.ani.datasources.api.Media import me.him188.ani.datasources.api.matcher.MediaSourceWebVideoMatcherLoader import me.him188.ani.datasources.api.matcher.WebVideoMatcher import me.him188.ani.datasources.api.matcher.WebVideoMatcherContext +import me.him188.ani.datasources.api.matcher.videoOrNull import me.him188.ani.datasources.api.topic.ResourceLocation import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import java.io.ByteArrayInputStream +import java.util.concurrent.ConcurrentSkipListSet + /** * 用 WebView 加载网站, 拦截 WebView 加载资源, 用各数据源提供的 [WebVideoMatcher] @@ -65,20 +69,36 @@ class AndroidWebVideoSourceResolver( override suspend fun resolve(media: Media, episode: EpisodeMetadata): VideoSource<*> { if (!supports(media)) throw UnsupportedMediaException(media) - val matcherContext = WebVideoMatcherContext(media) val matchersFromMediaSource = matcherLoader.loadMatchers(media.mediaSourceId) val allMatchers = matchersFromMediaSource + matchersFromClasspath + val context = WebVideoMatcherContext(media) + fun match(url: String): WebVideoMatcher.MatchResult? { + return allMatchers + .asSequence() + .map { matcher -> + matcher.match(url, context) + } + .firstOrNull { it !is WebVideoMatcher.MatchResult.Continue } + } + val webVideo = AndroidWebViewVideoExtractor().getVideoResourceUrl( attached ?: throw IllegalStateException("WebVideoSourceResolver not attached"), media.download.uri, resourceMatcher = { - allMatchers.firstNotNullOfOrNull { matcher -> - matcher.match(it, matcherContext) + when (match(it)) { + WebVideoMatcher.MatchResult.Continue -> Instruction.Continue + WebVideoMatcher.MatchResult.LoadPage -> Instruction.LoadPage + is WebVideoMatcher.MatchResult.Matched -> Instruction.FoundResource + null -> Instruction.Continue } }, - ) + )?.let { resource -> + allMatchers.firstNotNullOfOrNull { matcher -> + matcher.match(resource.url, context).videoOrNull + } + } ?: throw VideoSourceResolutionException(ResolutionFailures.NO_MATCHING_RESOURCE) return HttpStreamingVideoSource(webVideo.m3u8Url, media.originalTitle, webVideo = webVideo, media.extraFiles) } } @@ -86,63 +106,118 @@ class AndroidWebVideoSourceResolver( class AndroidWebViewVideoExtractor : WebViewVideoExtractor { private companion object { private val logger = logger() + private val consoleMessageUrlRegex = Regex("""'https?://.*?'""") } @OptIn(DelicateCoroutinesApi::class) @SuppressLint("SetJavaScriptEnabled") - override suspend fun getVideoResourceUrl( + override suspend fun getVideoResourceUrl( context: Context, pageUrl: String, - resourceMatcher: (String) -> R?, - ): R { - val deferred = CompletableDeferred() - withContext(Dispatchers.Main) { - val webView = WebView(context) - deferred.invokeOnCompletion { - GlobalScope.launch(Dispatchers.Main.immediate) { - webView.destroy() + resourceMatcher: (String) -> Instruction, + ): WebResource? { + // WebView requires same thread +// Executors.newSingleThreadExecutor().asCoroutineDispatcher().use { dispatcher -> + return withContext(Dispatchers.Main) { + val deferred = CompletableDeferred() + val loadedNestedUrls = ConcurrentSkipListSet() + + /** + * @return if the url has been consumed + */ + fun handleUrl(webView: WebView, url: String): Boolean { + val matched = resourceMatcher(url) + when (matched) { + Instruction.Continue -> return false + Instruction.FoundResource -> { + deferred.complete(WebResource(url)) + return true + } + + Instruction.LoadPage -> { + logger.info { "WebView loading nested page: $url" } + launch(Dispatchers.Main) { + @Suppress("LABEL_NAME_CLASH") + if (webView.url == url) return@launch // avoid infinite loop + if (!loadedNestedUrls.add(url)) return@launch + logger.info { "WebView navigating to new url: $url" } + webView.loadUrl(url) +// createWebView(context, deferred, ::handleUrl).loadUrl(url) + } + return false + } } } - webView.settings.javaScriptEnabled = true - - webView.webViewClient = object : WebViewClient() { - override fun shouldInterceptRequest( - view: WebView?, - request: WebResourceRequest? - ): WebResourceResponse? { - if (request == null) return null - val url = request.url ?: return super.shouldInterceptRequest(view, request) - if (url.toString().contains(".mp4")) { - logger.info { "Found url: $url" } - } - val matched = resourceMatcher(url.toString()) - if (matched != null) { - logger.info { "Found video resource via shouldInterceptRequest: $url" } - deferred.complete(matched) - - // 拦截, 以防资源只能加载一次 - return WebResourceResponse( - "text/plain", - "UTF-8", 500, - "Internal Server Error", - mapOf(), - ByteArrayInputStream(ByteArray(0)), - ) - } - return super.shouldInterceptRequest(view, request) + loadedNestedUrls.add(pageUrl) + createWebView(context, deferred, ::handleUrl).loadUrl(pageUrl) + + // webView.webChromeClient = object : WebChromeClient() { + // override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean { + // consoleMessage ?: return false + // val message = consoleMessage.message() ?: return false + // // HTTPS 页面加载 HTTP 的视频时会有日志 + // for (matchResult in consoleMessageUrlRegex.findAll(message)) { + // val url = matchResult.value.removeSurrounding("'") + // logger.info { "WebView console get url: $url" } + // handleUrl(url) + // } + // return false + // } + // } + + try { + deferred.await() + } catch (e: Throwable) { + if (deferred.isActive) { + deferred.cancel() } + throw e } - webView.loadUrl(pageUrl) } +// } + } + + @SuppressLint("SetJavaScriptEnabled") + @OptIn(DelicateCoroutinesApi::class) + private fun createWebView( + context: Context, + deferred: CompletableDeferred, + handleUrl: (WebView, String) -> Boolean, + ): WebView = WebView(context).apply { + val webView = this + deferred.invokeOnCompletion { + GlobalScope.launch(Dispatchers.Main.immediate) { + webView.destroy() + } + } + webView.settings.javaScriptEnabled = true + webView.webViewClient = object : WebViewClient() { + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url ?: return super.shouldInterceptRequest(view, request) + if (handleUrl(view, url.toString())) { + logger.info { "Found video resource via shouldInterceptRequest: $url" } + // 拦截, 以防资源只能加载一次 + return WebResourceResponse( + "text/plain", + "UTF-8", 500, + "Internal Server Error", + mapOf(), + ByteArrayInputStream(ByteArray(0)), + ) + } + return super.shouldInterceptRequest(view, request) + } - return try { - deferred.await() - } catch (e: Throwable) { - if (deferred.isActive) { - deferred.cancel() + override fun onLoadResource(view: WebView, url: String) { + if (handleUrl(view, url)) { + logger.info { "Found video resource via onLoadResource: $url" } + } + super.onLoadResource(view, url) } - throw e } } } diff --git a/app/shared/app-data/src/commonMain/kotlin/data/models/preference/VideoResolverSettings.kt b/app/shared/app-data/src/commonMain/kotlin/data/models/preference/VideoResolverSettings.kt index 97a0d29d03..40db33c4b8 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/models/preference/VideoResolverSettings.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/models/preference/VideoResolverSettings.kt @@ -1,13 +1,24 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.app.data.models.preference import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable import kotlinx.serialization.Transient +import me.him188.ani.app.data.models.preference.WebViewDriver.entries @Serializable @Immutable data class VideoResolverSettings( val driver: WebViewDriver = WebViewDriver.AUTO, + val headless: Boolean = true, @Suppress("PropertyName") @Transient val _placeholder: Int = 0, diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaSourceManager.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaSourceManager.kt index 75c9a969bc..58c0d1e531 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaSourceManager.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/fetch/MediaSourceManager.kt @@ -34,6 +34,7 @@ import me.him188.ani.app.data.source.media.cache.MediaCacheManager.Companion.LOC import me.him188.ani.app.data.source.media.instance.MediaSourceInstance import me.him188.ani.app.data.source.media.instance.MediaSourceSave import me.him188.ani.app.data.source.media.source.RssMediaSource +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSource import me.him188.ani.app.platform.getAniUserAgent import me.him188.ani.app.tools.ServiceLoader import me.him188.ani.datasources.api.matcher.MediaSourceWebVideoMatcherLoader @@ -193,6 +194,7 @@ class MediaSourceManagerImpl( add(MikanMediaSource.Factory()) // Kotlin bug, MPP 加载不了 resources add(MikanCNMediaSource.Factory()) add(RssMediaSource.Factory()) + add(SelectorMediaSource.Factory()) }.toList() private val additionalSources by lazy { diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt index 6a8433eead..fcf5fd4f83 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/VideoSourceResolver.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.app.data.source.media.resolver import androidx.compose.runtime.Composable @@ -80,6 +89,11 @@ enum class ResolutionFailures { NETWORK_ERROR, + /** + * Web 没有匹配到资源 + */ + NO_MATCHING_RESOURCE, + /** * 引擎自身错误 (bug) */ diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt index cd7eddecc2..f267e543e7 100644 --- a/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/resolver/WebViewVideoExtractor.kt @@ -11,17 +11,55 @@ package me.him188.ani.app.data.source.media.resolver import me.him188.ani.app.data.models.preference.ProxyConfig import me.him188.ani.app.data.models.preference.VideoResolverSettings +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor.Instruction import me.him188.ani.app.platform.Context +import me.him188.ani.utils.platform.annotations.TestOnly interface WebViewVideoExtractor { - suspend fun getVideoResourceUrl( + sealed class Instruction { + /** + * 继续加载这个链接 + */ + data object LoadPage : Instruction() + + /** + * 已经找到资源, 停止加载 + */ + data object FoundResource : Instruction() + + data object Continue : Instruction() + } + + suspend fun getVideoResourceUrl( context: Context, pageUrl: String, - resourceMatcher: (String) -> R?, - ): R + resourceMatcher: (String) -> Instruction, + ): WebResource? } +data class WebResource( + val url: String +) + expect fun WebViewVideoExtractor( proxyConfig: ProxyConfig?, videoResolverSettings: VideoResolverSettings, ): WebViewVideoExtractor + +@TestOnly +class TestWebViewVideoExtractor( + private val urls: (pageUrl: String) -> List, +) : WebViewVideoExtractor { + override suspend fun getVideoResourceUrl( + context: Context, + pageUrl: String, + resourceMatcher: (String) -> Instruction, + ): WebResource { + urls(pageUrl).forEach { + if (resourceMatcher(it) is Instruction.FoundResource) { + return WebResource(it) + } + } + throw IllegalStateException("No match found") + } +} diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt new file mode 100644 index 0000000000..ff6ca4cec5 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSource.kt @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + + +package me.him188.ani.app.data.source.media.source.web + +import io.ktor.client.request.get +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.serialization.Serializable +import me.him188.ani.app.data.models.ApiFailure +import me.him188.ani.app.data.models.ApiResponse +import me.him188.ani.app.data.models.fold +import me.him188.ani.app.data.models.map +import me.him188.ani.app.data.models.runApiRequest +import me.him188.ani.datasources.api.DefaultMedia +import me.him188.ani.datasources.api.matcher.WebVideoMatcher +import me.him188.ani.datasources.api.matcher.WebVideoMatcherProvider +import me.him188.ani.datasources.api.paging.SinglePagePagedSource +import me.him188.ani.datasources.api.paging.SizedSource +import me.him188.ani.datasources.api.paging.map +import me.him188.ani.datasources.api.paging.merge +import me.him188.ani.datasources.api.source.ConnectionStatus +import me.him188.ani.datasources.api.source.FactoryId +import me.him188.ani.datasources.api.source.HttpMediaSource +import me.him188.ani.datasources.api.source.MatchKind +import me.him188.ani.datasources.api.source.MediaFetchRequest +import me.him188.ani.datasources.api.source.MediaMatch +import me.him188.ani.datasources.api.source.MediaSource +import me.him188.ani.datasources.api.source.MediaSourceConfig +import me.him188.ani.datasources.api.source.MediaSourceFactory +import me.him188.ani.datasources.api.source.MediaSourceInfo +import me.him188.ani.datasources.api.source.MediaSourceKind +import me.him188.ani.datasources.api.source.MediaSourceLocation +import me.him188.ani.datasources.api.source.deserializeArgumentsOrNull +import me.him188.ani.datasources.api.source.useHttpClient + +@Suppress("unused") // bug +private typealias ArgumentType = SelectorMediaSourceArguments +private typealias EngineType = DefaultSelectorMediaSourceEngine + +/** + * [SelectorMediaSource] 的用户侧配置, 用于创建 [SelectorMediaSource] 实例. + * + * @since 3.10 + */ +@Serializable +data class SelectorMediaSourceArguments( + val name: String, + val description: String, + val iconUrl: String, + val searchConfig: SelectorSearchConfig = SelectorSearchConfig.Empty, +) { + companion object { + val Default = SelectorMediaSourceArguments( + name = "Selector", + description = "", + iconUrl = "", + searchConfig = SelectorSearchConfig.Empty, + ) + } +} + +/** + * @since 3.10 + */ +class SelectorMediaSource( + override val mediaSourceId: String, + config: MediaSourceConfig, + override val kind: MediaSourceKind = MediaSourceKind.WEB, +) : HttpMediaSource(), WebVideoMatcherProvider { + companion object { + val FactoryId = FactoryId("web-selector") + } + + private val arguments = + config.deserializeArgumentsOrNull(ArgumentType.serializer()) + ?: ArgumentType.Default + private val searchConfig = arguments.searchConfig + + private val client by lazy { useHttpClient(config) } + private val engine by lazy { EngineType(flowOf(client)) } + + override val location: MediaSourceLocation get() = MediaSourceLocation.Online + + class Factory : MediaSourceFactory { + override val factoryId: FactoryId get() = FactoryId + + override val info: MediaSourceInfo = MediaSourceInfo( + displayName = "Selector", + description = "通用 CSS Selector 数据源", + iconUrl = "", + ) + + override val allowMultipleInstances: Boolean get() = true + override fun create(mediaSourceId: String, config: MediaSourceConfig): MediaSource = + SelectorMediaSource(mediaSourceId, config) + } + + override suspend fun checkConnection(): ConnectionStatus { + return kotlin.runCatching { + runApiRequest { + client.get(searchConfig.searchUrl) // 提交一个请求, 只要它不是因为网络错误就行 + }.fold( + onSuccess = { ConnectionStatus.SUCCESS }, + onKnownFailure = { + when (it) { + ApiFailure.NetworkError -> ConnectionStatus.FAILED + ApiFailure.ServiceUnavailable -> ConnectionStatus.FAILED + ApiFailure.Unauthorized -> ConnectionStatus.SUCCESS + } + }, + ) + }.recover { + // 只要不是网络错误就行 + ConnectionStatus.SUCCESS + }.getOrThrow() + } + + override val info: MediaSourceInfo = MediaSourceInfo( + displayName = arguments.name, + description = arguments.description, + websiteUrl = searchConfig.searchUrl, + iconUrl = arguments.iconUrl, + ) + + // all-in-one search + private suspend fun EngineType.search( + searchConfig: SelectorSearchConfig, + query: SelectorSearchQuery, + mediaSourceId: String, + ): ApiResponse> { + return searchSubjects( + searchConfig.searchUrl, + subjectName = query.subjectName, + useOnlyFirstWord = searchConfig.searchUseOnlyFirstWord, + ).map { (_, document) -> + document ?: return@map emptyList() + val episodes = selectSubjects(document, searchConfig) + .orEmpty() + .let { originalList -> + val filters = searchConfig.createFiltersForSubject() + with(query.toFilterContext()) { + originalList.filter { + filters.applyOn(it.asCandidate()) + } + } + } + .mapNotNull { subject -> + doHttpGet(subject.subjectDetailsPageUrl) + .getOrNull() + } + .asSequence() + .mapNotNull { subjectDetails -> + selectEpisodes(subjectDetails, searchConfig) // null if invalid config + } + + selectMedia(episodes.flatMap { it.episodes }, searchConfig, query, mediaSourceId).filteredList + } + } + + override suspend fun fetch(query: MediaFetchRequest): SizedSource { + return query.subjectNames + .map { name -> + SinglePagePagedSource { + engine.search( + searchConfig, + SelectorSearchQuery( + subjectName = name, + episodeSort = query.episodeSort, + allSubjectNames = query.subjectNames, + ), + mediaSourceId, + ).getOrThrow().asFlow() + }.map { + MediaMatch(it, MatchKind.FUZZY) + } + }.merge() + } + + override val matcher: WebVideoMatcher by lazy(LazyThreadSafetyMode.NONE) { + WebVideoMatcher { url, _ -> + engine.matchWebVideo(url, arguments.searchConfig.matchVideo) + } + } +} \ No newline at end of file diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt new file mode 100644 index 0000000000..a16ee13994 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorMediaSourceEngine.kt @@ -0,0 +1,314 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.data.source.media.source.web + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.HttpStatusCode +import io.ktor.http.Url +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import me.him188.ani.app.data.models.ApiResponse +import me.him188.ani.app.data.models.runApiRequest +import me.him188.ani.app.data.source.media.source.MediaListFilter +import me.him188.ani.app.data.source.media.source.MediaListFilterContext +import me.him188.ani.app.data.source.media.source.MediaListFilters +import me.him188.ani.app.data.source.media.source.MediaSourceEngineHelpers +import me.him188.ani.app.data.source.media.source.asCandidate +import me.him188.ani.app.data.source.media.source.web.format.SelectedChannelEpisodes +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat.Companion.isPossiblyMovie +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatConfig +import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormat +import me.him188.ani.datasources.api.DefaultMedia +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.datasources.api.MediaProperties +import me.him188.ani.datasources.api.SubtitleKind +import me.him188.ani.datasources.api.matcher.WebVideo +import me.him188.ani.datasources.api.matcher.WebVideoMatcher +import me.him188.ani.datasources.api.source.MediaSourceKind +import me.him188.ani.datasources.api.source.MediaSourceLocation +import me.him188.ani.datasources.api.topic.EpisodeRange +import me.him188.ani.datasources.api.topic.FileSize +import me.him188.ani.datasources.api.topic.ResourceLocation +import me.him188.ani.datasources.api.topic.SubtitleLanguage +import me.him188.ani.utils.ktor.toSource +import me.him188.ani.utils.xml.Document +import me.him188.ani.utils.xml.Element +import me.him188.ani.utils.xml.Xml + +data class SelectorSearchQuery( + val subjectName: String, + val allSubjectNames: Set, + val episodeSort: EpisodeSort, +) + +fun SelectorSearchQuery.toFilterContext() = MediaListFilterContext( + subjectNames = allSubjectNames, + episodeSort = episodeSort, +) + +/** + * 解析流程: + * + * [SelectorMediaSourceEngine.searchSubjects] + * -> [SelectorMediaSourceEngine.selectSubjects] + * -> [SelectorMediaSourceEngine.searchEpisodes] + * -> [SelectorMediaSourceEngine.selectEpisodes] + */ +abstract class SelectorMediaSourceEngine { + companion object { + const val CURRENT_VERSION: UInt = 1u + } + + data class SearchSubjectResult( + val url: Url, + /** + * `null` means 404 + */ + val document: Document?, + ) { + override fun toString(): String = "SearchSubjectResult(url=$url, document=${document.toString().length}...)" + } + + suspend fun searchSubjects( + searchUrl: String, + subjectName: String, + useOnlyFirstWord: Boolean, + ): ApiResponse { + val encodedUrl = MediaSourceEngineHelpers.encodeUrlSegment( + if (useOnlyFirstWord) getFirstWord(subjectName) else subjectName, + ) + + val finalUrl = Url( + searchUrl.replace("{keyword}", encodedUrl), + ) + + return searchImpl(finalUrl) + } + + private fun getFirstWord(string: String): String { + if (!(string.contains(' '))) return string + return string.substringBefore(' ').ifBlank { string } + } + + protected abstract suspend fun searchImpl( + finalUrl: Url, + ): ApiResponse + + /** + * @return `null` if config is invalid + */ + open fun selectSubjects( + document: Element, + config: SelectorSearchConfig, + ): List? { + val subjectFormat = SelectorSubjectFormat.findById(config.subjectFormatId) + ?: throw UnsupportedOperationException("Unsupported subject format: ${config.subjectFormatId}") + + @Suppress("UNCHECKED_CAST") + subjectFormat as SelectorSubjectFormat + + val formatConfig = config.getFormatConfig(subjectFormat) + if (!formatConfig.isValid()) { + return null + } + val originalList = subjectFormat.select(document, config.baseUrl, formatConfig) + + return originalList + } + + suspend fun searchEpisodes( + subjectDetailsPageUrl: String, + ): ApiResponse = try { + doHttpGet(subjectDetailsPageUrl) + } catch (e: ClientRequestException) { + e.response.status.let { + if (it == HttpStatusCode.NotFound) { + return ApiResponse.success(null) + } + throw e + } + } + + /** + * @return `null` if config is invalid + */ + fun selectEpisodes( + subjectDetailsPage: Element, + config: SelectorSearchConfig, + ): SelectedChannelEpisodes? { + val channelFormat = SelectorChannelFormat.findById(config.channelFormatId) + ?: throw UnsupportedOperationException("Unsupported channel format: ${config.channelFormatId}") + + @Suppress("UNCHECKED_CAST") + channelFormat as SelectorChannelFormat + val formatConfig = config.getFormatConfig(channelFormat) + if (!formatConfig.isValid()) { + return null + } + return channelFormat.select( + subjectDetailsPage, + config.baseUrl, + formatConfig, + ) + } + + data class SelectMediaResult( + val originalList: List, + val filteredList: List, + ) + + fun selectMedia( + episodes: Sequence, + config: SelectorSearchConfig, + query: SelectorSearchQuery, + mediaSourceId: String, + ): SelectMediaResult { + val originalMediaList = episodes.mapNotNull { info -> + info.episodeSort ?: return@mapNotNull null + DefaultMedia( + mediaId = "$mediaSourceId.${info.name}-${info.episodeSort}", + mediaSourceId = mediaSourceId, + originalUrl = info.playUrl, + download = ResourceLocation.WebVideo(info.playUrl), + originalTitle = info.name, + publishedTime = 0L, + properties = MediaProperties( + subtitleLanguageIds = listOf(SubtitleLanguage.ChineseSimplified.id), + resolution = "1080P", + alliance = info.channel ?: mediaSourceId, + size = FileSize.Unspecified, + subtitleKind = SubtitleKind.EMBEDDED, + ), + episodeRange = EpisodeRange.single( + if (isPossiblyMovie(info.name) && info.episodeSort is EpisodeSort.Special) { + EpisodeSort(1) // 电影总是 01 + } else { + info.episodeSort + }, + ), + location = MediaSourceLocation.Online, + kind = MediaSourceKind.WEB, + ) + }.toList() + + return with(query.toFilterContext()) { + val filters = config.createFiltersForEpisode() + val filteredList = originalMediaList.filter { + filters.applyOn(it.asCandidate()) + } + SelectMediaResult(originalMediaList, filteredList) + } + } + + fun shouldLoadPage(url: String, config: SelectorSearchConfig.MatchVideoConfig): Boolean { + if (config.enableNestedUrl) { + config.matchNestedUrlRegex?.find(url)?.let { + return true + } + } + return false + } + + fun matchWebVideo(url: String, searchConfig: SelectorSearchConfig.MatchVideoConfig): WebVideoMatcher.MatchResult { + if (shouldLoadPage(url, searchConfig)) { + return WebVideoMatcher.MatchResult.LoadPage + } + + val result = searchConfig.matchVideoUrlRegex?.find(url) ?: return WebVideoMatcher.MatchResult.Continue + val videoUrl = try { + result.groups["v"]?.value ?: url + } catch (_: IllegalArgumentException) { // no group + url + } + + return WebVideoMatcher.MatchResult.Matched( + WebVideo( + videoUrl, + mapOf( + "User-Agent" to searchConfig.addHeadersToVideo.userAgent, + "Referer" to searchConfig.addHeadersToVideo.referer, + "Sec-Ch-Ua-Mobile" to "?0", + "Sec-Ch-Ua-Platform" to "macOS", + "Sec-Fetch-Dest" to "video", + "Sec-Fetch-Mode" to "no-cors", + "Sec-Fetch-Site" to "cross-site", + ), + ), + ) + } + + protected abstract suspend fun doHttpGet(uri: String): ApiResponse +} + +// TODO: require MediaListFilterContext when context parameters +fun WebSearchSubjectInfo.asCandidate(): MediaListFilter.Candidate { + val info = this + return object : MediaListFilter.Candidate { + override val originalTitle: String get() = info.name + override val episodeRange: EpisodeRange? get() = null + } +} + +/** + * If you change, you also need to change + */ +internal fun SelectorSearchConfig.createFiltersForSubject() = buildList { + if (filterBySubjectName) add(MediaListFilters.ContainsSubjectName) +} + +internal fun SelectorSearchConfig.createFiltersForEpisode() = buildList { + // 不使用 filterBySubjectName, 因为 web 的剧集名称通常为 "第x集", 不包含 subject + if (filterByEpisodeSort) add(MediaListFilters.ContainsEpisodeSort) +} + +class DefaultSelectorMediaSourceEngine( + /** + * Engine 自己不会 cache 实例, 每次都调用 `.first()`. + */ + private val client: Flow, +) : SelectorMediaSourceEngine() { + override suspend fun searchImpl( + finalUrl: Url, + ): ApiResponse = runApiRequest { + val document = try { + client.first().get(finalUrl).let { resp -> + Xml.parse(resp.bodyAsChannel().toSource()) + } + } catch (e: ClientRequestException) { + if (e.response.status == HttpStatusCode.NotFound) { + // 404 Not Found + return@runApiRequest SearchSubjectResult( + finalUrl, + document = null, + ) + } + throw e + } + + + SearchSubjectResult( + finalUrl, + document, + ) + } + + + public override suspend fun doHttpGet(uri: String): ApiResponse = + runApiRequest { + client.first().get(uri) { + }.let { resp -> + Xml.parse(resp.bodyAsChannel().toSource()) + } + } +} diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt new file mode 100644 index 0000000000..45c7132c5d --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/SelectorSearchConfig.kt @@ -0,0 +1,143 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.data.source.media.source.web + +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import io.ktor.http.URLBuilder +import kotlinx.serialization.Serializable +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatConfig +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId +import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatA +import me.him188.ani.app.data.source.media.source.web.format.parseOrNull +import org.intellij.lang.annotations.Language + +@Immutable +@Serializable +data class SelectorSearchConfig( + // Phase 1, search + val searchUrl: String = "", // required + val searchUseOnlyFirstWord: Boolean = true, + val preferShortest: Boolean = true, + // Phase 2, for search result, select subjects + val subjectFormatId: SelectorFormatId = SelectorSubjectFormatA.id, + val selectorSubjectFormatA: SelectorSubjectFormatA.Config = SelectorSubjectFormatA.Config(), + // Phase 3, for each subject, select channels + val channelFormatId: SelectorFormatId = SelectorChannelFormatNoChannel.id, + val selectorChannelFormatFlattened: SelectorChannelFormatFlattened.Config = SelectorChannelFormatFlattened.Config(), + val selectorChannelFormatNoChannel: SelectorChannelFormatNoChannel.Config = SelectorChannelFormatNoChannel.Config(), +// /** +// * Regex. Group names: +// * - ``: channel name +// * - ``: episode name +// * +// * E.g. 用于匹配 "线路1 第1集": +// * ```regex +// * (?.+)\s*第(?\d+)集 +// * ``` +// * +// * 匹配方式为 find 而不是 matchEntire. +// * @see SelectorChannelFormat.FLATTENED +// */ +// val matchChannelFromEpisodeText: String = "", +// val selectNameFromEpisode: String = "", +// val selectPlayUrlFromEpisode: String = "", + + + // Search done. Now we should have Medias. + val filterByEpisodeSort: Boolean = true, + val filterBySubjectName: Boolean = true, + + // When playing a media: + val matchVideo: MatchVideoConfig = MatchVideoConfig(), +) { // TODO: add Engine version capabilities + val baseUrl by lazy(LazyThreadSafetyMode.PUBLICATION) { + kotlin.runCatching { + URLBuilder(searchUrl).apply { + pathSegments = emptyList() + parameters.clear() + }.toString() + }.getOrElse { + val schemaIndex = searchUrl.indexOf("//") + if (schemaIndex == -1) { + searchUrl.removeSuffix("/") + } else { + val slashIndex = searchUrl.indexOf('/', startIndex = schemaIndex + 2) + if (slashIndex == -1) { + searchUrl.removeSuffix("/") + } else { + searchUrl.substring(0, slashIndex) + } + } + } + } + + @Serializable + @Suppress("RegExpRedundantEscape") + data class MatchVideoConfig( + val enableNestedUrl: Boolean = true, + @Language("regexp") + val matchNestedUrl: String = """^.+(m3u8|vip|xigua\.php).+\?""", + @Language("regexp") + val matchVideoUrl: String = """(^http(s)?:\/\/(?!.*http(s)?:\/\/).+((\.mp4)|(\.mkv)|(m3u8)).*(\?.+)?)|(akamaized)|(bilivideo.com)""", + val addHeadersToVideo: VideoHeaders = VideoHeaders(), + ) { + val matchNestedUrlRegex by lazy { + Regex.parseOrNull(matchNestedUrl) + } + val matchVideoUrlRegex by lazy { + Regex.parseOrNull(matchVideoUrl) + } + } + + @Serializable + data class VideoHeaders( + val referer: String = "", + val userAgent: String = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3", + ) + + +// val matchChannelFromEpisodeRegex by lazy(LazyThreadSafetyMode.PUBLICATION) { +// matchChannelFromEpisodeText.toRegex() +// } + + // These classes are nested to limit namespace + + @Stable + companion object { + @Stable + val Empty = SelectorSearchConfig() + } +} + +/** + * 获取该 [SelectorSubjectFormat] 的配置 [C]. + */ +fun SelectorSearchConfig.getFormatConfig(format: SelectorSubjectFormat): C { + @Suppress("UNCHECKED_CAST") + return when (format) { + SelectorSubjectFormatA -> selectorSubjectFormatA as C + } +} + +/** + * 获取该 [SelectorChannelFormat] 的配置 [C]. + */ +fun SelectorSearchConfig.getFormatConfig(format: SelectorChannelFormat): C { + @Suppress("UNCHECKED_CAST") + return when (format) { + SelectorChannelFormatFlattened -> selectorChannelFormatFlattened as C + SelectorChannelFormatNoChannel -> selectorChannelFormatNoChannel as C + } +} diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/WebSearchSubjectInfo.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/WebSearchSubjectInfo.kt new file mode 100644 index 0000000000..07abfdb31b --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/WebSearchSubjectInfo.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.data.source.media.source.web + +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.xml.Element + +data class WebSearchSubjectInfo( + val internalId: String, + val name: String, + val subjectDetailsPageUrl: String, + val origin: Element?, +) + +class WebSearchChannelInfo( + val name: String, + val content: Element, +) + +data class WebSearchEpisodeInfo( + /** + * 播放线路 + */ + val channel: String?, + /** + * "第x集" 等原名. + */ + val name: String, + /** + * 解析成功的 [EpisodeSort], 未解析成功则为 `null`. + */ + val episodeSort: EpisodeSort?, + /** + * 播放地址 + */ + val playUrl: String +) diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt new file mode 100644 index 0000000000..e7585da7e6 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorChannelFormat.kt @@ -0,0 +1,176 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.data.source.media.source.web.format + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine +import me.him188.ani.app.data.source.media.source.web.WebSearchEpisodeInfo +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.xml.Element +import me.him188.ani.utils.xml.QueryParser +import me.him188.ani.utils.xml.parseSelectorOrNull +import org.intellij.lang.annotations.Language + +/** + * 决定如何匹配线路和剧集 + * @see SelectorMediaSourceEngine + */ +sealed class SelectorChannelFormat(override val id: SelectorFormatId) : + SelectorFormat { + /** + * @return `null` for invalid config + * @see baseUrl must not end with `/` + */ + abstract fun select( + page: Element, + baseUrl: String, + config: Config, + ): SelectedChannelEpisodes? + + companion object { + val entries by lazy { // 必须 lazy, 否则可能获取到 null + listOf(SelectorChannelFormatNoChannel, SelectorChannelFormatFlattened) + } + + fun findById(id: SelectorFormatId): SelectorChannelFormat<*>? { + return entries.find { it.id == id } + } + + @Language("regexp") + const val DEFAULT_MATCH_EPISODE_SORT_FROM_NAME = """第\s*(?.+)\s*[话集]""" + + fun isPossiblyMovie(title: String): Boolean { + return ("简" in title || "繁" in title) + && ("2160P" in title || "1440P" in title || "2K" in title || "4K" in title || "1080P" in title || "720P" in title) + } + } +} + +data class SelectedChannelEpisodes( + /** + * `null` 表示该 format 不支持 (不考虑) channels + */ + val channels: List?, + val episodes: List, +) + + +/** + * 每个剧集标题内包含了线路名称. 例如 "主线第1集" + * + * 解析方式: + */ +data object SelectorChannelFormatFlattened : + SelectorChannelFormat(SelectorFormatId("flattened")) { + @Immutable + @Serializable + data class Config( + @Language("css") + val selectChannels: String = "body > div.box-width.cor5 > div.anthology.wow.fadeInUp.animated > div.anthology-tab.nav-swiper.b-b.br div.swiper-wrapper a.swiper-slide", + @Language("css") + val selectLists: String = "body > div.box-width.cor5 > div.anthology.wow.fadeInUp.animated > a", + @Language("css") + val selectElements: String = "a", + @Language("regexp") + val matchEpisodeSortFromName: String = DEFAULT_MATCH_EPISODE_SORT_FROM_NAME, + ) : SelectorFormatConfig { + override fun isValid(): Boolean { + return selectChannels.isNotBlank() && selectLists.isNotBlank() && selectElements.isNotBlank() && matchEpisodeSortFromName.isNotBlank() + } + } + + override fun select( + page: Element, + baseUrl: String, + config: Config, + ): SelectedChannelEpisodes? { + val selectChannels = QueryParser.parseSelectorOrNull(config.selectChannels) ?: return null + val selectElements = QueryParser.parseSelectorOrNull(config.selectElements) ?: return null + val selectLists = QueryParser.parseSelectorOrNull(config.selectLists) ?: return null + val matchEpisodeSortFromNameRegex = Regex.parseOrNull(config.matchEpisodeSortFromName) ?: return null + + val channels = page.select(selectChannels) + .map { e -> e.text().trim() } + + fun parseEps(ep: Element, channel: String?): List { + return ep.select(selectElements).mapNotNull { a -> + val text = a.text() + if (text in channels) return@mapNotNull null + + val href = a.attr("title").takeIf { it.isNotBlank() } ?: a.attr("href") + WebSearchEpisodeInfo( + channel = channel, + name = text, + episodeSort = matchEpisodeSortFromNameRegex.find(text)?.groups?.get("ep")?.value + ?.let { EpisodeSort(it) } + ?: EpisodeSort(text), + playUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href), + ) + } + } + + return SelectedChannelEpisodes( + channels, + page.select(selectLists) + .flatMapIndexed { i, e -> + val channel = channels.getOrNull(i) + parseEps(e, channel) + }, + ) + } +} + +/** + * 没有线路, 或者相当于只有一个线路. 只有一个 list of episodes + */ +data object SelectorChannelFormatNoChannel : + SelectorChannelFormat(SelectorFormatId("no-channel")) { + @Immutable + @Serializable + data class Config( + @Language("css") + val selectEpisodes: String = "#glist-1 > div.module-blocklist.scroll-box.scroll-box-y > div > a", + @Language("regexp") + val matchEpisodeSortFromName: String = DEFAULT_MATCH_EPISODE_SORT_FROM_NAME, + ) : SelectorFormatConfig { + val matchEpisodeSortFromNameRegex by lazy(LazyThreadSafetyMode.PUBLICATION) { + Regex.parseOrNull(matchEpisodeSortFromName) + } + + override fun isValid(): Boolean { + return selectEpisodes.isNotBlank() && matchEpisodeSortFromName.isNotBlank() + } + } + + override fun select( + page: Element, + baseUrl: String, + config: Config, + ): SelectedChannelEpisodes? { + val regex = config.matchEpisodeSortFromNameRegex ?: return null + val selectEpisodes = QueryParser.parseSelectorOrNull(config.selectEpisodes) ?: return null + return SelectedChannelEpisodes( + null, + page.select(selectEpisodes).map { a -> + val text = a.text() + val href = a.attr("href") + WebSearchEpisodeInfo( + channel = null, + name = text, + episodeSort = regex.find(text)?.groups?.get("ep")?.value + ?.let { EpisodeSort(it) } + ?: EpisodeSort(text), + playUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href), + ) + }, + ) + } +} diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt new file mode 100644 index 0000000000..e3e93604b8 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorFormat.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.data.source.media.source.web.format + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +import kotlin.jvm.JvmInline + +/** + * @see SelectorChannelFormat + * @see SelectorSubjectFormat + */ +interface SelectorFormat { + /** + * 永久唯一的命名. 用于序列化和反序列化. + */ + val id: SelectorFormatId +} + +@Serializable +@Immutable +@JvmInline +value class SelectorFormatId( + val value: String, +) + +interface SelectorFormatConfig { + /** + * 注意, 这只做最简单的检测, 例如是否为空. 不会检查是否合法. + */ + fun isValid(): Boolean +} + +fun Regex.Companion.parseOrNull(regex: String): Regex? { + return try { + regex.toRegex() + } catch (e: Exception) { + null + } +} + +object SelectorHelpers { + fun computeAbsoluteUrl(baseUrl: String, relativeUrl: String): String { + @Suppress("NAME_SHADOWING") + var baseUrl = baseUrl + if (baseUrl.endsWith('/')) { + baseUrl = baseUrl.dropLast(1) + } + return when { + relativeUrl.startsWith("http") -> relativeUrl + relativeUrl.startsWith('/') -> baseUrl + relativeUrl + else -> "$baseUrl/$relativeUrl" + } + } +} \ No newline at end of file diff --git a/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt new file mode 100644 index 0000000000..7bf241b415 --- /dev/null +++ b/app/shared/app-data/src/commonMain/kotlin/data/source/media/source/web/format/SelectorSubjectFormat.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.data.source.media.source.web.format + +import androidx.compose.runtime.Immutable +import kotlinx.serialization.Serializable +import me.him188.ani.app.data.source.media.source.web.WebSearchSubjectInfo +import me.him188.ani.utils.xml.Element +import me.him188.ani.utils.xml.QueryParser +import me.him188.ani.utils.xml.parseSelectorOrNull +import org.intellij.lang.annotations.Language + +/** + * 决定如何匹配条目 + */ +sealed class SelectorSubjectFormat(override val id: SelectorFormatId) : + SelectorFormat { // 方便改名 + + /** + * `null` means invalid config + */ + abstract fun select( + document: Element, + baseUrl: String, + config: Config, + ): List? + + companion object { + val entries by lazy { // 必须 lazy, 否则可能获取到 null + listOf(checkNotNull(SelectorSubjectFormatA)) // checkNotNull is needed to be fail-fast + } + + fun findById(id: SelectorFormatId): SelectorSubjectFormat<*>? { + // reflection is not supported in Kotlin/Native + return entries.find { it.id == id } + } + } +} + +/** + * Select 出一些 ``, text 作为 name, `href` 作为 url + */ +data object SelectorSubjectFormatA : SelectorSubjectFormat(SelectorFormatId("a")) { + @Immutable + @Serializable + data class Config( + @Language("css") + val selectLists: String = "div.video-info-header > a", + val preferShorterName: Boolean = true, + ) : SelectorFormatConfig { + override fun isValid(): Boolean { + return selectLists.isNotBlank() + } + } + + override fun select( + document: Element, + baseUrl: String, + config: Config, + ): List? { + val selectLists = QueryParser.parseSelectorOrNull(config.selectLists) ?: return null + val elements = document.select(selectLists) + return elements.mapTo(ArrayList(elements.size)) { a -> + val name = a.attr("title").takeIf { it.isNotBlank() } ?: a.text() + val href = a.attr("href") + val id = href.substringBeforeLast(".html").substringAfterLast("/") + WebSearchSubjectInfo( + internalId = id, + name = name, + subjectDetailsPageUrl = SelectorHelpers.computeAbsoluteUrl(baseUrl, href), + origin = a, + ) + }.apply { + if (config.preferShorterName) { + sortBy { info -> + info.name.length + } + } + } + } +} diff --git a/app/shared/app-data/src/desktopMain/kotlin/data/source/media/resolver/DesktopWebVideoSourceResolver.kt b/app/shared/app-data/src/desktopMain/kotlin/data/source/media/resolver/DesktopWebVideoSourceResolver.kt index fe6f2826b2..a6d778d093 100644 --- a/app/shared/app-data/src/desktopMain/kotlin/data/source/media/resolver/DesktopWebVideoSourceResolver.kt +++ b/app/shared/app-data/src/desktopMain/kotlin/data/source/media/resolver/DesktopWebVideoSourceResolver.kt @@ -20,6 +20,7 @@ import me.him188.ani.app.data.models.preference.ProxyConfig import me.him188.ani.app.data.models.preference.VideoResolverSettings import me.him188.ani.app.data.models.preference.WebViewDriver import me.him188.ani.app.data.repository.SettingsRepository +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor.Instruction import me.him188.ani.app.platform.Context import me.him188.ani.app.videoplayer.HttpStreamingVideoSource import me.him188.ani.app.videoplayer.data.VideoSource @@ -33,23 +34,17 @@ import me.him188.ani.utils.logging.info import me.him188.ani.utils.logging.logger import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.openqa.selenium.WebDriver import org.openqa.selenium.chrome.ChromeDriver import org.openqa.selenium.chrome.ChromeOptions import org.openqa.selenium.devtools.HasDevTools -import org.openqa.selenium.devtools.NetworkInterceptor +import org.openqa.selenium.devtools.v125.network.Network import org.openqa.selenium.edge.EdgeDriver import org.openqa.selenium.edge.EdgeOptions import org.openqa.selenium.remote.RemoteWebDriver -import org.openqa.selenium.remote.http.HttpHandler -import org.openqa.selenium.remote.http.HttpMethod -import org.openqa.selenium.remote.http.HttpResponse -import org.openqa.selenium.remote.http.Route import org.openqa.selenium.safari.SafariDriver import org.openqa.selenium.safari.SafariOptions -import org.openqa.selenium.support.events.EventFiringDecorator -import org.openqa.selenium.support.events.WebDriverListener -import java.lang.reflect.Method +import java.util.Optional +import java.util.function.Consumer import java.util.logging.Level /** @@ -74,16 +69,31 @@ class DesktopWebVideoSourceResolver( val matchersFromMediaSource = matcherLoader.loadMatchers(media.mediaSourceId) val allMatchers = matchersFromMediaSource + matchersFromClasspath + val context = WebVideoMatcherContext(media) + fun match(url: String): WebVideoMatcher.MatchResult? { + return allMatchers + .asSequence() + .map { matcher -> + matcher.match(url, context) + } + .firstOrNull { it !is WebVideoMatcher.MatchResult.Continue } + } + val webVideo = SeleniumWebViewVideoExtractor(config.config.takeIf { config.enabled }, resolverSettings) .getVideoResourceUrl( media.download.uri, resourceMatcher = { - allMatchers.firstNotNullOfOrNull { matcher -> - matcher.match(it, context) + when (match(it)) { + WebVideoMatcher.MatchResult.Continue -> Instruction.Continue + WebVideoMatcher.MatchResult.LoadPage -> Instruction.LoadPage + is WebVideoMatcher.MatchResult.Matched -> Instruction.FoundResource + null -> Instruction.Continue } }, - ) + )?.let { + (match(it.url) as? WebVideoMatcher.MatchResult.Matched)?.video + } ?: throw VideoSourceResolutionException(ResolutionFailures.NO_MATCHING_RESOURCE) return@withContext HttpStreamingVideoSource( webVideo.m3u8Url, media.originalTitle, @@ -110,198 +120,183 @@ class SeleniumWebViewVideoExtractor( } } - private fun createChromeDriver(): ChromeDriver { - WebDriverManager.chromedriver().setup() - return ChromeDriver( - ChromeOptions().apply { - addArguments("--headless") - addArguments("--disable-gpu") -// addArguments("--log-level=3") - proxyConfig?.let { - addArguments("--proxy-server=${it.url}") - } - }, - ) - } - - private fun createEdgeDriver(): EdgeDriver { - WebDriverManager.edgedriver().setup() - return EdgeDriver( - EdgeOptions().apply { - addArguments("--headless") - addArguments("--disable-gpu") -// addArguments("--log-level=3") - proxyConfig?.let { - addArguments("--proxy-server=${it.url}") - } - }, - ) - } - - /** - * SafariDriver does not support the use of proxies. - * https://github.com/SeleniumHQ/selenium/issues/10401#issuecomment-1054814944 - */ - private fun createSafariDriver(): SafariDriver { - WebDriverManager.safaridriver().setup() - return SafariDriver( - SafariOptions().apply { - proxyConfig?.let { - // Causes an exception - setCapability("proxy", it.url) - } - }, - ) - } - override suspend fun getVideoResourceUrl( + override suspend fun getVideoResourceUrl( context: Context, pageUrl: String, - resourceMatcher: (String) -> R? - ): R = getVideoResourceUrl(pageUrl, resourceMatcher) + resourceMatcher: (String) -> Instruction + ): WebResource? = getVideoResourceUrl(pageUrl, resourceMatcher) - suspend fun getVideoResourceUrl( + suspend fun getVideoResourceUrl( pageUrl: String, - resourceMatcher: (String) -> R? - ): R { - val deferred = CompletableDeferred() - - withContext(Dispatchers.IO) { - logger.info { "Starting Selenium with Edge to resolve video source from $pageUrl" } + resourceMatcher: (String) -> Instruction + ): WebResource? { + val deferred = CompletableDeferred() + return try { + withContext(Dispatchers.IO) { + logger.info { "Starting Selenium to resolve video source from $pageUrl" } + + val driver: RemoteWebDriver = createDriver() + + /** + * @return if the url has been consumed + */ + fun handleUrl(url: String): Boolean { + val matched = resourceMatcher(url) + when (matched) { + Instruction.Continue -> return false + Instruction.FoundResource -> { + deferred.complete(WebResource(url)) + return true + } - val driver: RemoteWebDriver = kotlin.run { - val primaryDriverFunction = mapWebViewDriverToFunction(videoResolverSettings.driver) - val fallbackDriverFunctions = getFallbackDriverFunctions(primaryDriverFunction) - - // Try user-set ones first, then fallback on the others - val driverCreationFunctions = listOfNotNull(primaryDriverFunction) + fallbackDriverFunctions - var successfulDriver: (() -> RemoteWebDriver)? = null - - val driver = driverCreationFunctions - .asSequence() - .mapNotNull { func -> - runCatching { - func().also { successfulDriver = func } - }.getOrNull() - } - .firstOrNull() - ?: throw Exception("Failed to create a driver") - - // If the rollback is successful, update the user settings - // Except Safari for now, because it does not support proxy settings and is not listed in the optional list - // updateDriverSettingsIfNeeded(successfulDriver) - - driver - } - - logger.info { "Using WebDriver: $driver" } - - val listener = object : WebDriverListener { - override fun beforeAnyNavigationCall( - navigation: WebDriver.Navigation?, - method: Method?, - args: Array? - ) { - logger.info { "Navigating to $pageUrl" } - } - - override fun afterAnyNavigationCall( - navigation: WebDriver.Navigation?, - method: Method?, - args: Array?, - result: Any? - ) { - logger.info { "Navigated to $pageUrl" } - } - - override fun beforeGet(driver: WebDriver?, url: String?) { - if (driver == null || url == null) return - resourceMatcher(url)?.let { matched -> - logger.info { "Found video resource via beforeGet: $url" } - deferred.complete(matched) + Instruction.LoadPage -> { + if (driver.currentUrl == url) return false // don't recurse + logger.info { "WebView loading nested page: $url" } + val script = "window.location.href = '$url'" + logger.info { "WebView executing: $script" } + driver.executeScript(script) +// decoratedDriver.get(url) + return false + } } } - } - - check(driver is HasDevTools) { - "WebDriver must support DevTools" - } - val decoratedDriver: WebDriver = - EventFiringDecorator(WebDriver::class.java, listener).decorate(driver) - val emptyResponseHandler = HttpHandler { _ -> - HttpResponse().apply { - status = 500 - } - } - val route: Route = Route.matching { req -> - if (HttpMethod.GET != req.method) return@matching false + logger.info { "Using WebDriver: $driver" } - val url = req.uri - val matched = resourceMatcher(url) - if (matched != null) { - logger.info { "Found video resource via network interception: $url" } - deferred.complete(matched) - return@matching true + check(driver is HasDevTools) { + "WebDriver must support DevTools" } - false - }.to { emptyResponseHandler } - - val interceptor = NetworkInterceptor(driver, route) - deferred.invokeOnCompletion { - @Suppress("OPT_IN_USAGE") - GlobalScope.launch { - kotlin.runCatching { - interceptor.close() - decoratedDriver.quit() - }.onFailure { - logger.error(it) { "Failed to close selenium" } + val devTools = driver.devTools + devTools.createSession() +// devTools.send(Network.enable(Optional.of(10000000), Optional.of(10000000), Optional.of(10000000))) + devTools.send(Network.enable(Optional.empty(), Optional.empty(), Optional.empty())) + devTools.addListener( + Network.requestWillBeSent(), + Consumer { + val url = it.request.url + if (handleUrl(url)) { + logger.info { "Found video resource via devtools: $url" } + } + }, + ) + devTools.send(Network.clearBrowserCache()) + + deferred.invokeOnCompletion { + @Suppress("OPT_IN_USAGE") + GlobalScope.launch { + kotlin.runCatching { + driver.quit() + }.onFailure { + logger.error(it) { "Failed to close selenium" } + } } } - } + driver.get(pageUrl) + } - decoratedDriver.navigate().to(pageUrl) - } - - return try { deferred.await() } catch (e: Throwable) { if (deferred.isActive) { - deferred.cancel() + deferred.cancel() // will quit driver } throw e } } + private fun createDriver(): RemoteWebDriver { + return getPreferredDriverFactory() + .runCatching { + create(videoResolverSettings, proxyConfig) + } + .let { + // 依次尝试备用 + var result = it + for (fallback in getFallbackDrivers()) { + result = result.recoverCatching { + fallback.create(videoResolverSettings, proxyConfig) + } + } + result + } + .getOrThrow() + // TODO: update user settings if we fell back to a different driver + } + - private fun mapWebViewDriverToFunction(driver: WebViewDriver): (() -> RemoteWebDriver)? { - return when (driver) { - WebViewDriver.CHROME -> ::createChromeDriver - WebViewDriver.EDGE -> ::createEdgeDriver - else -> null + private fun getPreferredDriverFactory(): WebDriverFactory { + return when (videoResolverSettings.driver) { + WebViewDriver.CHROME -> WebDriverFactory.Chrome + WebViewDriver.EDGE -> WebDriverFactory.Edge + else -> WebDriverFactory.Chrome } } - private fun getFallbackDriverFunctions(primaryDriverFunction: (() -> RemoteWebDriver)?): List<() -> RemoteWebDriver> { + private fun getFallbackDrivers(): List { return listOf( - ::createChromeDriver, - ::createEdgeDriver, -// ::createSafariDriver, - ).filter { it != primaryDriverFunction } + WebDriverFactory.Chrome, + WebDriverFactory.Edge, + ) } +} -// private fun updateDriverSettingsIfNeeded(successfulDriver: (() -> RemoteWebDriver)?, primaryDriverFunction: (() -> RemoteWebDriver)?) { -// if (successfulDriver != primaryDriverFunction) { -// val fallbackDriverType = when (successfulDriver) { -// ::createEdgeDriver -> WebViewDriver.EDGE -// ::createChromeDriver -> WebViewDriver.CHROME -// else -> null -// } -// if (fallbackDriverType != null) { -// // TODO: update driver settings -// } -// } -// } -} \ No newline at end of file +private sealed interface WebDriverFactory { + fun create(videoResolverSettings: VideoResolverSettings, proxyConfig: ProxyConfig?): RemoteWebDriver + + data object Edge : WebDriverFactory { + override fun create(videoResolverSettings: VideoResolverSettings, proxyConfig: ProxyConfig?): RemoteWebDriver { + WebDriverManager.edgedriver().setup() + return EdgeDriver( + EdgeOptions().apply { + if (videoResolverSettings.headless) { + addArguments("--headless") + addArguments("--disable-gpu") + } +// addArguments("--log-level=3") + proxyConfig?.let { + addArguments("--proxy-server=${it.url}") + } + }, + ) + } + } + + data object Chrome : WebDriverFactory { + override fun create(videoResolverSettings: VideoResolverSettings, proxyConfig: ProxyConfig?): RemoteWebDriver { + WebDriverManager.chromedriver().setup() + return ChromeDriver( + ChromeOptions().apply { + if (videoResolverSettings.headless) { + addArguments("--headless") + addArguments("--disable-gpu") + } +// addArguments("--log-level=3") + proxyConfig?.let { + addArguments("--proxy-server=${it.url}") + } + }, + ) + } + } + + @Deprecated("Safari is not supported") + data object Safari : WebDriverFactory { + /** + * SafariDriver does not support the use of proxies. + * https://github.com/SeleniumHQ/selenium/issues/10401#issuecomment-1054814944 + */ // 而且还要求用户去设置里开启开发者模式 + override fun create(videoResolverSettings: VideoResolverSettings, proxyConfig: ProxyConfig?): RemoteWebDriver { + WebDriverManager.safaridriver().setup() + return SafariDriver( + SafariOptions().apply { + proxyConfig?.let { + // Causes an exception + setCapability("proxy", it.url) + } + }, + ) + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt b/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt index c8b77a5697..a1dbb8326e 100644 --- a/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt +++ b/app/shared/src/commonMain/kotlin/ui/main/AniAppContentPortrait.kt @@ -34,6 +34,7 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument import me.him188.ani.app.data.source.media.source.RssMediaSource +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSource import me.him188.ani.app.navigation.AniNavigator import me.him188.ani.app.navigation.LocalNavigator import me.him188.ani.app.platform.LocalContext @@ -56,6 +57,8 @@ import me.him188.ani.app.ui.settings.SettingsTab import me.him188.ani.app.ui.settings.SettingsViewModel import me.him188.ani.app.ui.settings.mediasource.rss.EditRssMediaSourcePage import me.him188.ani.app.ui.settings.mediasource.rss.EditRssMediaSourceViewModel +import me.him188.ani.app.ui.settings.mediasource.selector.EditSelectorMediaSourcePage +import me.him188.ani.app.ui.settings.mediasource.selector.EditSelectorMediaSourceViewModel import me.him188.ani.app.ui.settings.tabs.media.torrent.peer.PeerFilterSettingsPage import me.him188.ani.app.ui.settings.tabs.media.torrent.peer.PeerFilterSettingsViewModel import me.him188.ani.app.ui.subject.cache.SubjectCacheScene @@ -305,6 +308,17 @@ fun AniAppContentPortrait( windowInsets, ) + SelectorMediaSource.FactoryId -> { + val context = LocalContext.current + EditSelectorMediaSourcePage( + viewModel(key = mediaSourceInstanceId) { + EditSelectorMediaSourceViewModel(mediaSourceInstanceId, context) + }, + Modifier, + windowInsets = windowInsets, + ) + } + else -> error("Unknown factoryId: $factoryId") } } diff --git a/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt b/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt index 3ac11c3a26..439c77e748 100644 --- a/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt +++ b/app/shared/src/commonMain/kotlin/ui/subject/episode/video/PlayerLauncher.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.app.ui.subject.episode.video import kotlinx.coroutines.CancellationException @@ -121,6 +130,7 @@ class PlayerLauncher( ResolutionFailures.FETCH_TIMEOUT -> VideoLoadingState.ResolutionTimedOut ResolutionFailures.ENGINE_ERROR -> VideoLoadingState.UnknownError(e) ResolutionFailures.NETWORK_ERROR -> VideoLoadingState.NetworkError + ResolutionFailures.NO_MATCHING_RESOURCE -> VideoLoadingState.NoMatchingFile } playerState.clearVideoSource() } catch (e: CancellationException) { // 切换数据源 diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt new file mode 100644 index 0000000000..273b789b25 --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.android.kt @@ -0,0 +1,62 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +@file:OptIn(TestOnly::class) + +package me.him188.ani.app.ui.settings.mediasource.selector.edit + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.Preview +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.app.ui.settings.mediasource.rss.createTestSaveableStorage +import me.him188.ani.utils.platform.annotations.TestOnly + +@Composable +@TestOnly +fun rememberTestSelectorConfigurationState( + arguments: SelectorMediaSourceArguments = SelectorMediaSourceArguments.Default +): SelectorConfigState { + return remember { + SelectorConfigState( + createTestSaveableStorage( + arguments, + ), + ) + } +} + +@Composable +@Preview +private fun PreviewSelectorChannelConfigurationColumnNotFound() = ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorChannelConfigurationColumn(SelectorFormatId("dummy"), rememberTestSelectorConfigurationState()) + } +} + +@Composable +@Preview +private fun PreviewSelectorChannelConfigurationColumnFlattened() = ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorChannelConfigurationColumn(SelectorChannelFormatFlattened.id, rememberTestSelectorConfigurationState()) + } +} + +@Composable +@Preview +private fun PreviewSelectorChannelConfigurationColumnNoChannel() = ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorChannelConfigurationColumn(SelectorChannelFormatNoChannel.id, rememberTestSelectorConfigurationState()) + } +} diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt new file mode 100644 index 0000000000..66f0d2da8c --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/edit/SelectorEditPane.android.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.edit + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.tooling.preview.Preview +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.app.ui.foundation.preview.PHONE_LANDSCAPE +import me.him188.ani.app.ui.settings.mediasource.rss.createTestSaveableStorage +import me.him188.ani.utils.platform.annotations.TestOnly + +@OptIn(TestOnly::class) +@Composable +@Preview(PHONE_LANDSCAPE) +fun PreviewSelectorConfigurationPane() = ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorConfigurationPane( + remember { + SelectorConfigState( + createTestSaveableStorage( + SelectorMediaSourceArguments.Default, + ), + ) + }, + ) + } +} + diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt new file mode 100644 index 0000000000..04dfe3cb47 --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.android.kt @@ -0,0 +1,142 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.episode + +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.tooling.preview.Preview +import me.him188.ani.app.data.source.media.resolver.TestWebViewVideoExtractor +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.platform.LocalContext +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.app.ui.foundation.stateOf +import me.him188.ani.app.ui.settings.mediasource.rss.createTestSaveableStorage +import me.him188.ani.app.ui.settings.mediasource.rss.test.buildMatchTags +import me.him188.ani.app.ui.settings.mediasource.selector.EditSelectorMediaSourcePageState +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestEpisodePresentation +import me.him188.ani.app.ui.settings.mediasource.selector.test.TestSelectorMediaSourceEngine +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.platform.annotations.TestOnly +import kotlin.coroutines.EmptyCoroutineContext + +@OptIn(TestOnly::class) +@Composable +@Preview +fun PreviewSelectorEpisodePaneCompact() = ProvideFoundationCompositionLocalsForPreview { + Surface { + val state = rememberTestEditSelectorMediaSourceState( + SelectorSearchConfig.MatchVideoConfig(), + ) + SelectorTestAndEpisodePane( + state = state, + layout = SelectorEpisodePaneLayout.Compact, + ) + SideEffect { + state.viewEpisode(TestSelectorTestEpisodePresentations[0]) + } + } +} + +@OptIn(TestOnly::class) +@Composable +@Preview(device = "spec:width=1280dp,height=800dp,dpi=240") +fun PreviewSelectorEpisodePaneExpanded() { + ProvideFoundationCompositionLocalsForPreview { + Surface { + SelectorTestAndEpisodePane( + state = rememberTestEditSelectorMediaSourceState(), + layout = SelectorEpisodePaneLayout.Expanded, + initialRoute = SelectorEpisodePaneRoutes.EPISODE, + ) + } + } +} + +@TestOnly +@Stable +internal val TestSelectorTestEpisodePresentations + get() = listOf( + SelectorTestEpisodePresentation( + name = "Test Episode 2", + episodeSort = EpisodeSort(2), + playUrl = "https://example.com", + tags = buildMatchTags { + emit("EP: 02", isMatch = true) + emit("https://example.com", isMatch = true) + }, + origin = null, + ), + SelectorTestEpisodePresentation( + name = "Test Episode Unknown", + episodeSort = null, + playUrl = "https://example.com", + tags = buildMatchTags { + emit("缺失 EP", isMissing = true) + }, + origin = null, + ), + ) + +@TestOnly +@Composable +internal fun rememberTestSelectorEpisodeState( + item: SelectorTestEpisodePresentation? = TestSelectorTestEpisodePresentations[0], + config: SelectorSearchConfig.MatchVideoConfig = SelectorSearchConfig.MatchVideoConfig(), + urls: (pageUrl: String) -> List = { + listOf("https://example.com/a.mkv") + }, +): SelectorEpisodeState { + val scope = rememberCoroutineScope() + val context = LocalContext.current + return remember { + SelectorEpisodeState( + itemState = stateOf(item), + matchVideoConfigState = stateOf(config), + webViewVideoExtractor = stateOf(TestWebViewVideoExtractor(urls)), + engine = TestSelectorMediaSourceEngine(), + backgroundScope = scope, + flowDispatcher = EmptyCoroutineContext, + context = context, + ) + } +} + +@TestOnly +@Composable +internal fun rememberTestEditSelectorMediaSourceState( + matchVideoConfig: SelectorSearchConfig.MatchVideoConfig = SelectorSearchConfig.MatchVideoConfig(), + urls: (pageUrl: String) -> List = { + listOf("https://example.com/a.mkv") + }, +): EditSelectorMediaSourcePageState { + val context = LocalContext.current + val scope = rememberCoroutineScope() + return remember { + EditSelectorMediaSourcePageState( + createTestSaveableStorage( + SelectorMediaSourceArguments.Default.run { + copy( + searchConfig = searchConfig.copy(matchVideo = matchVideoConfig), + ) + }, + ), + engine = TestSelectorMediaSourceEngine(), + webViewVideoExtractor = stateOf(TestWebViewVideoExtractor(urls)), + backgroundScope = scope, + context, + flowDispatcher = EmptyCoroutineContext, + ) + } +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt new file mode 100644 index 0000000000..aca4af2553 --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.android.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +@file:OptIn(TestOnly::class) + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.tooling.preview.Preview +import io.ktor.http.Url +import me.him188.ani.app.data.models.ApiResponse +import me.him188.ani.app.data.models.networkError +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.data.source.media.source.web.WebSearchSubjectInfo +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.utils.platform.annotations.TestOnly +import me.him188.ani.utils.xml.Document +import me.him188.ani.utils.xml.Element + +@Composable +@SuppressLint("UnusedContentLambdaTargetStateParameter") +@Preview +fun PreviewSelectorTestPane() = ProvideFoundationCompositionLocalsForPreview { + val scope = rememberCoroutineScope() + SharedTransitionScope { modifier -> + @Suppress("AnimatedContentLabel") + AnimatedContent(1) { _ -> + Surface { + SelectorTestPane( + remember { + SelectorTestState( + searchConfigState = mutableStateOf(SelectorSearchConfig.Empty), + engine = TestSelectorMediaSourceEngine(), + scope, + ).apply { + subjectSearcher.restartCurrentSearch() + } + }, + {}, + this, + modifier = modifier, + ) + } + } + } +} + +@TestOnly +class TestSelectorMediaSourceEngine : SelectorMediaSourceEngine() { + override suspend fun searchImpl( + finalUrl: Url + ): ApiResponse { + return ApiResponse.success( + SearchSubjectResult( + Url("https://example.com"), + null, + ), + ) + } + + override fun selectSubjects(document: Element, config: SelectorSearchConfig): List { + return listOf( + WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null), + WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null), + WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null), + WebSearchSubjectInfo("a", "Test Subject", "https://example.com", null), + ) + } + + override suspend fun doHttpGet(uri: String): ApiResponse { + return ApiResponse.networkError() + } +} diff --git a/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.android.kt b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.android.kt new file mode 100644 index 0000000000..c3e1f378fb --- /dev/null +++ b/app/shared/ui-settings/src/androidMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.android.kt @@ -0,0 +1,100 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Button +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.tooling.preview.Preview +import me.him188.ani.app.ui.foundation.ProvideFoundationCompositionLocalsForPreview +import me.him188.ani.app.ui.settings.mediasource.rss.test.MatchTag +import me.him188.ani.utils.platform.annotations.TestOnly +import me.him188.ani.utils.xml.Element +import kotlin.random.Random +import kotlin.random.nextInt + +@TestOnly +fun createTestSelectorTestSubjectPresentation( + name: String, + subjectDetailsPageUrl: String = "", + origin: Element? = null, + tags: List = emptyList(), +): SelectorTestSubjectPresentation { + return SelectorTestSubjectPresentation( + name, subjectDetailsPageUrl, origin, tags, + ) +} + +@TestOnly +val TestSelectorTestSubjectPresentations + get() = listOf( + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚", + tags = listOf(MatchTag("标题", isMatch = false), MatchTag("字幕", isMatch = true)), + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚".repeat(10), + tags = listOf(MatchTag("标题", isMatch = false)), + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚".repeat(10), + tags = listOf(MatchTag("标题", isMatch = false)), + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚".repeat(10), + tags = listOf(MatchTag("标题", isMatch = false)), + ), + createTestSelectorTestSubjectPresentation( + "孤独摇滚".repeat(10), + tags = listOf(MatchTag("标题", isMatch = false)), + ), + ) + +@OptIn(TestOnly::class) +@Composable +@Preview +private fun PreviewSubjectResultLazyRow() = ProvideFoundationCompositionLocalsForPreview { + Surface { + Column { + var selectedIndex by remember { mutableIntStateOf(1) } + SelectorTestSubjectResultLazyRow( + TestSelectorTestSubjectPresentations, + selectedItemIndex = selectedIndex, + { _, _ -> }, + ) + + Row { + Text("Selected index: $selectedIndex") + Button({ selectedIndex = Random.nextInt(TestSelectorTestSubjectPresentations.indices) }) { + Text("Select random") + } + } + } + } +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/RefreshIndicatedHeadlineRow.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/RefreshIndicatedHeadlineRow.kt index 5a11c9a826..0d882d1aab 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/RefreshIndicatedHeadlineRow.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/RefreshIndicatedHeadlineRow.kt @@ -92,7 +92,7 @@ fun RefreshIndicatedHeadlineRow( result: RefreshResult?, modifier: Modifier = Modifier, refreshIcon: @Composable () -> Unit = { RefreshIndicationDefaults.RefreshIconButton(onRefresh) }, - style: TextStyle = MaterialTheme.typography.headlineSmall, + style: TextStyle = MaterialTheme.typography.titleLarge, ) { Row(modifier, verticalAlignment = Alignment.CenterVertically) { ProvideTextStyle(style) { @@ -114,7 +114,7 @@ object RefreshIndicationDefaults { onClick: () -> Unit, modifier: Modifier = Modifier, ) { - TextButton( + IconButton( onClick = onClick, modifier = modifier, ) { diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt new file mode 100644 index 0000000000..3e5c94663a --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/EditSelectorMediaSourcePage.kt @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator +import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavHostController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine +import me.him188.ani.app.platform.Context +import me.him188.ani.app.ui.foundation.interaction.WindowDragArea +import me.him188.ani.app.ui.foundation.layout.AnimatedPane1 +import me.him188.ani.app.ui.foundation.layout.isWidthCompact +import me.him188.ani.app.ui.foundation.layout.materialWindowMarginPadding +import me.him188.ani.app.ui.foundation.layout.rememberConnectedScrollState +import me.him188.ani.app.ui.foundation.navigation.BackHandler +import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults +import me.him188.ani.app.ui.foundation.widgets.TopAppBarGoBackButton +import me.him188.ani.app.ui.settings.mediasource.rss.SaveableStorage +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigState +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationPane +import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodePaneDefaults +import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodePaneLayout +import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodePaneRoutes +import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorEpisodeState +import me.him188.ani.app.ui.settings.mediasource.selector.episode.SelectorTestAndEpisodePane +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestEpisodePresentation +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestState +import kotlin.coroutines.CoroutineContext + +class EditSelectorMediaSourcePageState( + argumentsStorage: SaveableStorage, + engine: SelectorMediaSourceEngine, + webViewVideoExtractor: State, + backgroundScope: CoroutineScope, + context: Context, + flowDispatcher: CoroutineContext = Dispatchers.Default, +) { + internal val configurationState: SelectorConfigState = SelectorConfigState(argumentsStorage) + + internal val testState: SelectorTestState = + SelectorTestState(configurationState.searchConfigState, engine, backgroundScope) + + private val viewingItemState = mutableStateOf(null) + + var viewingItem by viewingItemState + private set + + lateinit var episodeNavController: NavHostController + internal set // set from ui + + fun viewEpisode( + episode: SelectorTestEpisodePresentation, + ) { + this.viewingItem = episode + if (episodeNavController.currentDestination?.hasRoute() != true) { + episodeNavController.navigate(SelectorEpisodePaneRoutes.EPISODE) + } + } + + fun stopViewing() { + this.viewingItem = null + if (episodeNavController.currentDestination?.hasRoute() != true) { + episodeNavController.navigate(SelectorEpisodePaneRoutes.TEST) + } + } + + + internal val episodeState: SelectorEpisodeState = SelectorEpisodeState( + itemState = viewingItemState, + matchVideoConfigState = derivedStateOf { configurationState.searchConfigState.value?.matchVideo }, + webViewVideoExtractor = webViewVideoExtractor, + engine = engine, + backgroundScope = backgroundScope, + context = context, + flowDispatcher = flowDispatcher, + ) +} + +@Composable +fun EditSelectorMediaSourcePage( + vm: EditSelectorMediaSourceViewModel, + modifier: Modifier = Modifier, + navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), + windowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, +) { + val state by vm.state.collectAsStateWithLifecycle(null) + state?.let { + EditSelectorMediaSourcePage(it, modifier, navigator, windowInsets) + } +} + +@Composable +fun EditSelectorMediaSourcePage( + state: EditSelectorMediaSourcePageState, + modifier: Modifier = Modifier, + navigator: ThreePaneScaffoldNavigator = rememberListDetailPaneScaffoldNavigator(), + windowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets, +) { + val episodePaneLayout = SelectorEpisodePaneLayout.calculate(navigator.scaffoldValue) + val testConnectedScrollState = rememberConnectedScrollState() + Scaffold( + modifier, + topBar = { + WindowDragArea { + val viewingItem = state.viewingItem + if (viewingItem != null && episodePaneLayout.showTopBarInScaffold) { + SelectorEpisodePaneDefaults.TopAppBar( + state.episodeState, + windowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + ) + } else { + TopAppBar( + title = { + if (viewingItem != null) { + Text(viewingItem.name) + } else { + Text(state.configurationState.displayName) + } + }, + navigationIcon = { TopAppBarGoBackButton() }, + actions = { + if (currentWindowAdaptiveInfo().isWidthCompact && navigator.currentDestination?.pane != ListDetailPaneScaffoldRole.Detail) { + TextButton({ navigator.navigateTo(ListDetailPaneScaffoldRole.Detail) }) { + Text("测试") + } + } + }, + windowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top), + colors = AniThemeDefaults.topAppBarColors(), + ) + } + } + }, + contentWindowInsets = windowInsets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom), + ) { paddingValues -> + BackHandler(navigator.canNavigateBack()) { + navigator.navigateBack() + } + + // 在外面启动, 避免在切换页面后重新启动导致刷新 + LaunchedEffect(state) { + state.testState.subjectSearcher.observeChangeLoop() + } + LaunchedEffect(state) { + state.testState.episodeListSearcher.observeChangeLoop() + } + LaunchedEffect(state) { + state.episodeState.searcher.observeChangeLoop() + } + + ListDetailPaneScaffold( + navigator.scaffoldDirective, + navigator.scaffoldValue, + listPane = { + AnimatedPane1(Modifier.preferredWidth(480.dp)) { + SelectorConfigurationPane( + state = state.configurationState, + Modifier.fillMaxSize().consumeWindowInsets(paddingValues), + contentPadding = paddingValues, + ) + } + }, + detailPane = { + AnimatedPane1 { + SelectorTestAndEpisodePane( + state = state, + layout = episodePaneLayout, + modifier = Modifier.consumeWindowInsets(paddingValues), + contentPadding = paddingValues, + testConnectedScrollState = testConnectedScrollState, + ) + } + }, + Modifier.materialWindowMarginPadding(), + ) + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt new file mode 100644 index 0000000000..e2831d9dc8 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/SelectorMediaSourceConfigurationPage.kt @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf +import io.ktor.client.plugins.BrowserUserAgent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transformLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import me.him188.ani.app.data.repository.SettingsRepository +import me.him188.ani.app.data.source.media.fetch.MediaSourceManager +import me.him188.ani.app.data.source.media.fetch.toClientProxyConfig +import me.him188.ani.app.data.source.media.fetch.updateMediaSourceArguments +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor +import me.him188.ani.app.data.source.media.source.web.DefaultSelectorMediaSourceEngine +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.platform.Context +import me.him188.ani.app.tools.MonoTasker +import me.him188.ani.app.ui.foundation.AbstractViewModel +import me.him188.ani.app.ui.settings.mediasource.rss.SaveableStorage +import me.him188.ani.datasources.api.source.HttpMediaSource +import me.him188.ani.datasources.api.source.createHttpClient +import me.him188.ani.datasources.api.source.deserializeArgumentsOrNull +import me.him188.ani.utils.coroutines.onReplacement +import me.him188.ani.utils.ktor.proxy +import me.him188.ani.utils.ktor.registerLogging +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +private typealias ArgumentsType = SelectorMediaSourceArguments + +@Stable +class EditSelectorMediaSourceViewModel( + initialInstanceId: String, + context: Context, +) : AbstractViewModel(), KoinComponent { + private val mediaSourceManager: MediaSourceManager by inject() + private val settingsRepository: SettingsRepository by inject() + + private val instanceId: MutableStateFlow = MutableStateFlow(initialInstanceId) + + private val arguments = this.instanceId.flatMapLatest { instanceId -> + mediaSourceManager.instanceConfigFlow(instanceId).map { + it?.deserializeArgumentsOrNull( + ArgumentsType.serializer(), + ) ?: ArgumentsType.Default + } + } + + val state: Flow = this.instanceId.transformLatest { instanceId -> + coroutineScope { + val saveTasker = MonoTasker(this) + val arguments = mutableStateOf(null) + launch { + val persisted = mediaSourceManager.instanceConfigFlow(instanceId).first() + ?.deserializeArgumentsOrNull(ArgumentsType.serializer()) + ?: ArgumentsType.Default + withContext(Dispatchers.Main) { + arguments.value = persisted + } + } + emit( + EditSelectorMediaSourcePageState( + argumentsStorage = SaveableStorage( + arguments, + onSave = { + arguments.value = it + saveTasker.launch { + delay(500) + mediaSourceManager.updateMediaSourceArguments( + instanceId, + ArgumentsType.serializer(), + it, + ) + } + }, + isSavingState = derivedStateOf { saveTasker.isRunning }, + ), + engine = DefaultSelectorMediaSourceEngine(client), + webViewVideoExtractor = combine( + settingsRepository.proxySettings.flow.map { it.default.config }.distinctUntilChanged(), + settingsRepository.videoResolverSettings.flow.distinctUntilChanged(), + ) { proxySettings, videoResolverSettings -> + WebViewVideoExtractor(proxySettings, videoResolverSettings) + }.produceState(null), + backgroundScope = this, + context, + flowDispatcher = Dispatchers.Default, + ), + ) + } + }.flowOn(Dispatchers.Default) + + private val client = settingsRepository.proxySettings.flow.map { + HttpMediaSource.createHttpClient { + proxy(it.default.toClientProxyConfig()) + BrowserUserAgent() + }.apply { + registerLogging(logger) + } + }.onReplacement { + it.close() + }.shareInBackground(started = SharingStarted.Lazily) + +// val testState: RssTestPaneState = RssTestPaneState( +// // 这里用的是序列化之后的配置, 也就是只有保存成功之后, 才会更新测试 (和触发重新查询) +// searchConfigState = arguments.map { it.searchConfig }.produceState(RssSearchConfig.Empty), +// engine = DefaultRssMediaSourceEngine(client, parser = RssParser(includeOrigin = true)), +// backgroundScope, +// ) + + override fun onCleared() { + super.onCleared() + client.replayCache.firstOrNull()?.close() + } +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt new file mode 100644 index 0000000000..468a9b606c --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorChannelConfigurationColumn.kt @@ -0,0 +1,127 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.edit + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Error +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId +import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter +import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding +import me.him188.ani.app.ui.foundation.text.ProvideTextStyleContentColor +import me.him188.ani.app.ui.settings.mediasource.MediaSourceConfigurationDefaults + +@Composable +internal fun SelectorChannelConfigurationColumn( + formatId: SelectorFormatId, + state: SelectorConfigState, + modifier: Modifier = Modifier, + textFieldShape: Shape = MediaSourceConfigurationDefaults.outlinedTextFieldShape, +) { + Column(modifier) { + when (SelectorChannelFormat.findById(formatId)) { + SelectorChannelFormatFlattened -> Column( + verticalArrangement = Arrangement.spacedBy(currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding), + ) { + val conf = state.channelFormatIndexed + OutlinedTextField( + conf.selectChannels, { conf.selectChannels = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("提取线路名称列表") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("CSS Selector 表达式。将会读取结果的 text") }, + isError = conf.selectChannelsIsError, + ) + OutlinedTextField( + conf.selectLists, { conf.selectLists = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("提取所有线路的剧集列表") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("CSS Selector 表达式。期望返回一些 ,每个对应一个剧集,将会读取其 href 属性和 text") }, + isError = conf.selectListsIsError, + ) + OutlinedTextField( + conf.matchEpisodeSortFromName, { conf.matchEpisodeSortFromName = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("从剧集名称中匹配序号") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("正则表达式查找。期望名为 ep 的分组") }, + isError = conf.matchEpisodeSortFromNameIsError, + ) + } + + SelectorChannelFormatNoChannel -> Column( + verticalArrangement = Arrangement.spacedBy(currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding), + ) { + val conf = state.channelFormatNoChannel + OutlinedTextField( + conf.selectEpisodes, { conf.selectEpisodes = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("提取剧集列表") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("CSS Selector 表达式。期望返回一些 ,每个对应一个剧集,将会读取其 href 属性和 text") }, + isError = conf.selectEpisodesIsError, + ) + OutlinedTextField( + conf.matchEpisodeSortFromName, { conf.matchEpisodeSortFromName = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("从剧集名称中匹配序号") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + supportingText = { Text("正则表达式查找。期望名为 ep 的分组") }, + isError = conf.matchEpisodeSortFromNameIsError, + ) + } + + null -> { + Column( + Modifier.align(Alignment.CenterHorizontally), + ) { + ProvideTextStyleContentColor(MaterialTheme.typography.bodyLarge, MaterialTheme.colorScheme.error) { + Icon( + Icons.Rounded.Error, null, + Modifier.align(Alignment.CenterHorizontally).size(48.dp), + ) + Text( + "当前版本不支持该配置类型:${formatId.value}\n\n这可能是导入了一个在更高版本编辑的配置导致的\n可升级 Ani 或切换到其他配置类型", + Modifier.padding(top = 24.dp), + textAlign = TextAlign.Center, + ) + } + } + } + } + } +} + diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt new file mode 100644 index 0000000000..188d9649e5 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigState.kt @@ -0,0 +1,233 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.edit + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormat +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorSubjectFormatA +import me.him188.ani.app.ui.settings.danmaku.isValidRegex +import me.him188.ani.app.ui.settings.mediasource.rss.SaveableStorage +import me.him188.ani.utils.xml.QueryParser +import me.him188.ani.utils.xml.parseSelectorOrNull + +/** + * 编辑配置 + */ +@Stable +class SelectorConfigState( + private val argumentsStorage: SaveableStorage, +) { + private val arguments by argumentsStorage.containerState + val isLoading by derivedStateOf { arguments == null } + val isSaving by argumentsStorage.isSavingState + + var displayName by argumentsStorage.prop( + { it.name }, { copy(name = it) }, + SelectorMediaSourceArguments.Default.name, + ) + + val displayNameIsError by derivedStateOf { displayName.isBlank() } + + var iconUrl by argumentsStorage.prop( + { it.iconUrl }, { copy(iconUrl = it) }, + SelectorMediaSourceArguments.Default.iconUrl, + ) + + var searchUrl by argumentsStorage.prop( + { it.searchConfig.searchUrl }, { copy(searchConfig = searchConfig.copy(searchUrl = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.searchUrl, + ) + val searchUrlIsError by derivedStateOf { searchUrl.isBlank() } + + var searchUseOnlyFirstWord by argumentsStorage.prop( + { it.searchConfig.searchUseOnlyFirstWord }, + { copy(searchConfig = searchConfig.copy(searchUseOnlyFirstWord = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.searchUseOnlyFirstWord, + ) + + // region SubjectFormat + + val subjectFormatA = SubjectFormatAConfig() + + @Stable + inner class SubjectFormatAConfig { + private fun prop( + get: (SelectorSubjectFormatA.Config) -> T, + set: SelectorSubjectFormatA.Config.(T) -> SelectorSubjectFormatA.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorSubjectFormatA.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorSubjectFormatA = searchConfig.selectorSubjectFormatA.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorSubjectFormatA.let(get), + ) + + var selectLists by prop({ it.selectLists }, { copy(selectLists = it) }) + val selectListsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectLists) == null + } + var preferShorterName by prop({ it.preferShorterName }, { copy(preferShorterName = it) }) + } + + // endregion + + // region ChannelFormat + + var channelFormatId by argumentsStorage.prop( + { it.searchConfig.channelFormatId }, { copy(searchConfig = searchConfig.copy(channelFormatId = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.channelFormatId, + ) + val allChannelFormats get() = SelectorChannelFormat.entries + + val channelFormatIndexed = ChannelFormatIndexedConfig() + + @Stable + inner class ChannelFormatIndexedConfig { + private fun prop( + get: (SelectorChannelFormatFlattened.Config) -> T, + set: SelectorChannelFormatFlattened.Config.(T) -> SelectorChannelFormatFlattened.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorChannelFormatFlattened.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorChannelFormatFlattened = searchConfig.selectorChannelFormatFlattened.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorChannelFormatFlattened.let(get), + ) + + var selectChannels by prop({ it.selectChannels }, { copy(selectChannels = it) }) + val selectChannelsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectChannels) == null + } + var selectLists by prop({ it.selectLists }, { copy(selectLists = it) }) + val selectListsIsError by derivedStateOf { + QueryParser.parseSelectorOrNull(selectLists) == null + } + var matchEpisodeSortFromName by prop({ it.matchEpisodeSortFromName }, { copy(matchEpisodeSortFromName = it) }) + val matchEpisodeSortFromNameIsError by derivedStateOf { + matchEpisodeSortFromName.isBlank() || !isValidRegex(matchEpisodeSortFromName) + } + } + + val channelFormatNoChannel = ChannelFormatNoChannelConfig() + + @Stable + inner class ChannelFormatNoChannelConfig { + private fun prop( + get: (SelectorChannelFormatNoChannel.Config) -> T, + set: SelectorChannelFormatNoChannel.Config.(T) -> SelectorChannelFormatNoChannel.Config, + ) = argumentsStorage.prop( + { it.searchConfig.selectorChannelFormatNoChannel.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + selectorChannelFormatNoChannel = searchConfig.selectorChannelFormatNoChannel.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.selectorChannelFormatNoChannel.let(get), + ) + + var selectEpisodes by prop({ it.selectEpisodes }, { copy(selectEpisodes = it) }) + val selectEpisodesIsError by derivedStateOf { QueryParser.parseSelectorOrNull(selectEpisodes) == null } + var matchEpisodeSortFromName by prop( + { it.matchEpisodeSortFromName }, + { copy(matchEpisodeSortFromName = it) }, + ) + val matchEpisodeSortFromNameIsError by derivedStateOf { + matchEpisodeSortFromName.isBlank() || !isValidRegex(matchEpisodeSortFromName) + } + } + + // endregion + + var filterByEpisodeSort by argumentsStorage.prop( + { it.searchConfig.filterByEpisodeSort }, { copy(searchConfig = searchConfig.copy(filterByEpisodeSort = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.filterByEpisodeSort, + ) + var filterBySubjectName by argumentsStorage.prop( + { it.searchConfig.filterBySubjectName }, { copy(searchConfig = searchConfig.copy(filterBySubjectName = it)) }, + SelectorMediaSourceArguments.Default.searchConfig.filterBySubjectName, + ) + + val matchVideoConfig: MatchVideoConfig = MatchVideoConfig() + + @Stable + inner class MatchVideoConfig { + private fun prop( + get: (SelectorSearchConfig.MatchVideoConfig) -> T, + set: SelectorSearchConfig.MatchVideoConfig.(T) -> SelectorSearchConfig.MatchVideoConfig, + ) = argumentsStorage.prop( + { it.searchConfig.matchVideo.let(get) }, + { + copy( + searchConfig = searchConfig.copy( + matchVideo = searchConfig.matchVideo.set(it), + ), + ) + }, + SelectorMediaSourceArguments.Default.searchConfig.matchVideo.let(get), + ) + + var matchVideoUrl by prop( + { it.matchVideoUrl }, { copy(matchVideoUrl = it) }, + ) + val matchVideoUrlIsError by derivedStateOf { + matchVideoUrl.isBlank() || !isValidRegex(matchVideoUrl) + } + + var enableNestedUrl by prop( + { it.enableNestedUrl }, { copy(enableNestedUrl = it) }, + ) + var matchNestedUrl by prop( + { it.matchNestedUrl }, { copy(matchNestedUrl = it) }, + ) + val matchNestedUrlIsError by derivedStateOf { + matchNestedUrl.isBlank() || !isValidRegex(matchNestedUrl) + } + + val videoHeaders = HeadersConfig() + + @Stable + inner class HeadersConfig { + private fun prop( + get: (SelectorSearchConfig.VideoHeaders) -> T, + set: SelectorSearchConfig.VideoHeaders.(T) -> SelectorSearchConfig.VideoHeaders, + ) = this@MatchVideoConfig.prop( + { it.addHeadersToVideo.let(get) }, + { copy(addHeadersToVideo = addHeadersToVideo.set(it)) }, + ) + + var referer by prop( + { it.referer }, { copy(referer = referer) }, + ) + var userAgent by prop( + { it.userAgent }, { copy(userAgent = userAgent) }, + ) + } + } + + val searchConfigState = derivedStateOf { + argumentsStorage.container?.searchConfig + } +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt new file mode 100644 index 0000000000..932781ae79 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationDefaults.kt @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.edit + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color.Companion.Transparent +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter +import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding + +/** + * @see me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceArguments + */ +object SelectorConfigurationDefaults { + const val STEP_NAME_1 = "步骤 1:搜索条目" + const val STEP_NAME_2 = "步骤 2:搜索剧集" + const val STEP_NAME_3 = "步骤 3:匹配视频" + + val verticalSpacing: Dp + @Composable + get() = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding + + val textFieldShape + @Composable + get() = MaterialTheme.shapes.medium +} + +@Suppress("UnusedReceiverParameter") +@Composable +internal fun SelectorConfigurationDefaults.MatchVideoSection( + state: SelectorConfigState, + modifier: Modifier = Modifier, + textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, + verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, +) { + Column(modifier) { + val matchVideoConfig = state.matchVideoConfig + ListItem( + headlineContent = { Text("启用嵌套链接") }, + Modifier + .padding(bottom = (verticalSpacing - 8.dp).coerceAtLeast(0.dp)) + .clickable { matchVideoConfig.enableNestedUrl = !matchVideoConfig.enableNestedUrl }, + supportingContent = { Text("当遇到匹配的链接时,终止父页面加载并跳转到匹配的链接,在嵌套页面中继续查找视频链接。支持任意次数嵌套") }, + trailingContent = { + Switch(matchVideoConfig.enableNestedUrl, { matchVideoConfig.enableNestedUrl = it }) + }, + colors = ListItemDefaults.colors(containerColor = Transparent), + ) + + AnimatedVisibility(visible = matchVideoConfig.enableNestedUrl) { + OutlinedTextField( + matchVideoConfig.matchNestedUrl, { matchVideoConfig.matchNestedUrl = it }, + Modifier + .fillMaxWidth() + .moveFocusOnEnter() + .padding(bottom = verticalSpacing), + label = { Text("匹配嵌套链接") }, + supportingText = { Text("正则表达式,从播放页面中加载的所有资源链接中匹配出需要跳转进入的链接。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + isError = matchVideoConfig.matchNestedUrlIsError, + ) + } + + OutlinedTextField( + matchVideoConfig.matchVideoUrl, { matchVideoConfig.matchVideoUrl = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("匹配视频链接") }, + supportingText = { Text("正则表达式,从播放页面中加载的所有资源链接中匹配出视频链接。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + isError = matchVideoConfig.matchVideoUrlIsError, + ) + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt new file mode 100644 index 0000000000..1a4eefcac3 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/edit/SelectorConfigurationPane.kt @@ -0,0 +1,316 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.edit + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatFlattened +import me.him188.ani.app.data.source.media.source.web.format.SelectorChannelFormatNoChannel +import me.him188.ani.app.data.source.media.source.web.format.SelectorFormatId +import me.him188.ani.app.ui.foundation.animation.StandardEasing +import me.him188.ani.app.ui.foundation.effects.moveFocusOnEnter +import me.him188.ani.app.ui.foundation.text.ProvideTextStyleContentColor +import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults +import me.him188.ani.app.ui.foundation.theme.EasingDurations +import me.him188.ani.app.ui.settings.mediasource.rss.edit.MediaSourceHeadline + +@Composable +internal fun SelectorConfigurationPane( + state: SelectorConfigState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, + textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, +) { + Column( + modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(contentPadding), + ) { + // 大图标和标题 + MediaSourceHeadline(state.iconUrl, state.displayName) + + Column( + Modifier + .fillMaxHeight() + .padding(vertical = 16.dp), + ) { + val listItemColors = ListItemDefaults.colors(containerColor = Color.Transparent) + + Column(verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + OutlinedTextField( + state.displayName, { state.displayName = it }, + Modifier + .fillMaxWidth() + .moveFocusOnEnter(), + label = { Text("名称*") }, + placeholder = { Text("设置显示在列表中的名称") }, + isError = state.displayNameIsError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + OutlinedTextField( + state.iconUrl, { state.iconUrl = it }, + Modifier + .fillMaxWidth() + .moveFocusOnEnter(), + label = { Text("图标链接") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + } + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text(SelectorConfigurationDefaults.STEP_NAME_1) + } + } + + Column { + OutlinedTextField( + state.searchUrl, { state.searchUrl = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("搜索链接*") }, + placeholder = { + Text( + "示例:https://www.nyacg.net/search.html?wd={keyword}", + color = MaterialTheme.colorScheme.outline, + ) + }, + supportingText = { + Text( + """ + 替换规则: + {keyword} 替换为条目 (番剧) 名称 + """.trimIndent(), + ) + }, + isError = state.searchUrlIsError, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + ListItem( + headlineContent = { Text("仅使用第一个词") }, + Modifier + .padding(top = (verticalSpacing - 8.dp).coerceAtLeast(0.dp)) + .clickable { state.searchUseOnlyFirstWord = !state.searchUseOnlyFirstWord }, + supportingContent = { Text("以空格分割,仅使用第一个词搜索。适用于搜索兼容性差的情况") }, + trailingContent = { + Switch(state.searchUseOnlyFirstWord, { state.searchUseOnlyFirstWord = it }) + }, + colors = listItemColors, + ) + + val conf = state.subjectFormatA + OutlinedTextField( + conf.selectLists, { conf.selectLists = it }, + Modifier.fillMaxWidth().moveFocusOnEnter().padding(top = verticalSpacing), + label = { Text("提取条目列表") }, + supportingText = { Text("CSS Selector 表达式。期望返回一些 ,每个对应一个条目,将会读取其 href 属性和 text") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + isError = conf.selectListsIsError, + ) + ListItem( + headlineContent = { Text("选择最短标题") }, + Modifier + .padding(top = (verticalSpacing - 8.dp).coerceAtLeast(0.dp)) + .clickable { conf.preferShorterName = !conf.preferShorterName }, + supportingContent = { Text("选择满足匹配的标题最短的条目。可避免为第一季匹配到第二季") }, + trailingContent = { + Switch(conf.preferShorterName, { conf.preferShorterName = it }) + }, + colors = listItemColors, + ) + } + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text(SelectorConfigurationDefaults.STEP_NAME_2) + } + } + + SubjectChannelSelectionButtonRow( + state, + Modifier.fillMaxWidth().padding(bottom = 4.dp), + ) + + AnimatedContent( + state.channelFormatId, + Modifier + .padding(vertical = 16.dp) + .fillMaxWidth() + .animateContentSize(tween(EasingDurations.standard, easing = StandardEasing)), + transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, + ) { formatId -> + SelectorChannelConfigurationColumn(formatId, state, Modifier.fillMaxWidth()) + } + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text("过滤设置") + } + } + + Column( + Modifier, + verticalArrangement = Arrangement.spacedBy((verticalSpacing - 16.dp).coerceAtLeast(0.dp)), + ) { + ListItem( + headlineContent = { Text("使用条目名称过滤") }, + Modifier.focusable(false).clickable { state.filterBySubjectName = !state.filterBySubjectName }, + supportingContent = { Text("要求资源标题包含条目名称。适用于数据源可能搜到无关内容的情况。通常建议开启") }, + trailingContent = { Switch(state.filterBySubjectName, { state.filterBySubjectName = it }) }, + colors = listItemColors, + ) + ListItem( + headlineContent = { Text("使用剧集序号过滤") }, + Modifier.focusable(false).clickable { state.filterByEpisodeSort = !state.filterByEpisodeSort }, + supportingContent = { Text("要求资源标题包含剧集序号。适用于数据源可能搜到无关内容的情况。通常建议开启") }, + trailingContent = { Switch(state.filterByEpisodeSort, { state.filterByEpisodeSort = it }) }, + colors = listItemColors, + ) + } + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text(SelectorConfigurationDefaults.STEP_NAME_3) + } + } + + SelectorConfigurationDefaults.MatchVideoSection( + state, + textFieldShape = textFieldShape, + verticalSpacing = verticalSpacing, + ) + + Row(Modifier.padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.titleMedium, + MaterialTheme.colorScheme.primary, + ) { + Text("播放视频时") + } + } + + Column(Modifier, verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + val conf = state.matchVideoConfig.videoHeaders + OutlinedTextField( + conf.referer, { conf.referer = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("Referer") }, + supportingText = { Text("播放视频时执行的 HTTP 请求的 Referer,可留空") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + OutlinedTextField( + conf.userAgent, { conf.userAgent = it }, + Modifier.fillMaxWidth().moveFocusOnEnter(), + label = { Text("User-Agent") }, + supportingText = { Text("播放视频时执行的 HTTP 请求的 User-Agent") }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + shape = textFieldShape, + ) + } + + Row(Modifier.align(Alignment.End).padding(top = verticalSpacing, bottom = 12.dp)) { + ProvideTextStyleContentColor( + MaterialTheme.typography.labelMedium, + MaterialTheme.colorScheme.outline, + ) { + Text("提示:修改自动保存") + } + } + } + + } +} + +@Composable +private fun SubjectChannelSelectionButtonRow( + state: SelectorConfigState, + modifier: Modifier = Modifier, +) { + SingleChoiceSegmentedButtonRow(modifier) { + @Composable + fun Btn( + id: SelectorFormatId, index: Int, + enabled: Boolean = true, + label: @Composable () -> Unit, + ) { + SegmentedButton( + state.channelFormatId == id, + { state.channelFormatId = id }, + SegmentedButtonDefaults.itemShape(index, state.allChannelFormats.size), + icon = { SegmentedButtonDefaults.Icon(state.channelFormatId == id) }, + label = label, + enabled = enabled, + ) + } + + for ((index, selectorChannelFormat) in state.allChannelFormats.withIndex()) { + Btn( + selectorChannelFormat.id, index, + enabled = selectorChannelFormat == SelectorChannelFormatNoChannel, + ) { + Text( + when (selectorChannelFormat) { // type-safe to handle all formats + SelectorChannelFormatNoChannel -> "不区分线路" + SelectorChannelFormatFlattened -> "(其他暂未支持)" + }, + softWrap = false, + ) + } + } + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt new file mode 100644 index 0000000000..33c2b4532e --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePane.kt @@ -0,0 +1,364 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.episode + +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.PriorityHigh +import androidx.compose.material.icons.rounded.Verified +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.Card +import androidx.compose.material3.CardColors +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemColors +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole +import androidx.compose.material3.adaptive.layout.PaneAdaptedValue +import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import me.him188.ani.app.ui.foundation.layout.ConnectedScrollState +import me.him188.ani.app.ui.foundation.layout.paneHorizontalPadding +import me.him188.ani.app.ui.foundation.layout.rememberConnectedScrollState +import me.him188.ani.app.ui.foundation.navigation.BackHandler +import me.him188.ani.app.ui.foundation.widgets.FastLinearProgressIndicator +import me.him188.ani.app.ui.foundation.widgets.LocalToaster +import me.him188.ani.app.ui.settings.mediasource.selector.EditSelectorMediaSourcePageState +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationDefaults +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestPane + +@Composable +fun SelectorTestAndEpisodePane( + state: EditSelectorMediaSourcePageState, + layout: SelectorEpisodePaneLayout, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + testConnectedScrollState: ConnectedScrollState = rememberConnectedScrollState(), + initialRoute: SelectorEpisodePaneRoutes = SelectorEpisodePaneRoutes.TEST, +) { + val nestedNav = rememberNavController() + state.episodeNavController = nestedNav + + SharedTransitionScope { transitionModifier -> + NavHost(nestedNav, initialRoute, modifier.then(transitionModifier)) { + composable { + SelectorTestPane( + state.testState, + onViewEpisode = { + state.viewEpisode(it) + }, + this, + Modifier.fillMaxSize(), + contentPadding = contentPadding, + connectedScrollState = testConnectedScrollState, + ) + } + composable { + BackHandler { + state.stopViewing() + nestedNav.popBackStack(SelectorEpisodePaneRoutes.EPISODE, inclusive = true) + } + val cardColors: CardColors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + ) + + // decorate + val content: @Composable () -> Unit = { + SelectorEpisodePaneContent( + state.episodeState, + Modifier.fillMaxSize(), + itemColors = ListItemDefaults.colors(containerColor = cardColors.containerColor), + ) + } + val topAppBarDecorated = if (layout.showTopBarInPane) { + { + // list 展开, 能编辑配置 + Card( + Modifier + .sharedBounds(rememberSharedContentState(state.episodeState.lastNonNullId), this) + .fillMaxSize(), + colors = cardColors, + shape = MaterialTheme.shapes.large, + ) { + SelectorEpisodePaneDefaults.TopAppBar(state.episodeState) + content() + } + } + } else content + + val bottomSheetDecorated = if (layout.showBottomSheet) { + { + BottomSheetScaffold( + sheetContent = { + SelectorEpisodePaneDefaults.ConfigurationContent( + state.configurationState, + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + ) + }, + Modifier + .fillMaxSize(), + sheetPeekHeight = 78.dp, + ) { paddingValues -> + Box(Modifier.padding(paddingValues)) { + topAppBarDecorated() + } + } + } + } else topAppBarDecorated + + Box(Modifier.padding(contentPadding)) { + bottomSheetDecorated() + } + } + } + } +} + + +@Composable +fun SelectorEpisodePaneContent( + state: SelectorEpisodeState, + modifier: Modifier = Modifier, + itemSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, + horizontalPadding: Dp = currentWindowAdaptiveInfo().windowSizeClass.paneHorizontalPadding, + itemColors: ListItemColors = ListItemDefaults.colors(), +) { + Column(modifier) { + Box(Modifier.height(4.dp), contentAlignment = Alignment.Center) { + FastLinearProgressIndicator( + state.isSearchingInProgress, + delayMillis = 0, + minimumDurationMillis = 300, + ) + } + + val list by state.rawMatchResults.collectAsStateWithLifecycle(emptyList()) + + Row( + Modifier.padding( + start = horizontalPadding, end = horizontalPadding, + top = 20.dp, + bottom = 20.dp, + ), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val matchedVideoSize by remember { + derivedStateOf { + list.count { it.isMatchedVideo() } + } + } + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + when (matchedVideoSize) { + 0 -> { + Icon( + Icons.Rounded.PriorityHigh, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text("根据步骤 3 的配置,从 ${list.size} 个链接中未匹配到播放链接,请检查配置") + } + + 1 -> { + Icon( + Icons.Rounded.Verified, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Text("根据步骤 3 的配置,从 ${list.size} 个链接中匹配到了 $matchedVideoSize 个链接") + } + + else -> { + Icon( + Icons.Rounded.PriorityHigh, + contentDescription = null, + tint = Color.Yellow.compositeOver(MaterialTheme.colorScheme.error), + ) + Text("根据步骤 3 的配置,从 ${list.size} 个链接中匹配到了 $matchedVideoSize 个链接。为了更好的稳定性,建议调整规则,匹配到正好一个链接") + } + } + } + } + + FlowRow( + Modifier.padding(horizontal = horizontalPadding).padding(bottom = 20.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + FilterChip( + selected = state.hideImages, + { state.hideImages = !state.hideImages }, + label = { Text("隐藏图片") }, + leadingIcon = { if (state.hideImages) Icon(Icons.Rounded.Check, null) }, + ) + FilterChip( + selected = state.hideCss, + { state.hideCss = !state.hideCss }, + label = { Text("隐藏 CSS/字体") }, + leadingIcon = { if (state.hideCss) Icon(Icons.Rounded.Check, null) }, + ) + FilterChip( + selected = state.hideScripts, + { state.hideScripts = !state.hideScripts }, + label = { Text("隐藏 JS/WASM") }, + leadingIcon = { if (state.hideScripts) Icon(Icons.Rounded.Check, null) }, + ) + FilterChip( + selected = state.hideData, + { state.hideData = !state.hideData }, + label = { Text("隐藏 data") }, + leadingIcon = { if (state.hideData) Icon(Icons.Rounded.Check, null) }, + ) + } + + val filteredList by state.filteredResults.collectAsStateWithLifecycle(emptyList()) + + LazyColumn( + contentPadding = PaddingValues( + bottom = itemSpacing, + start = horizontalPadding - 8.dp, end = horizontalPadding, + ), + ) { + // 上面总是有个东西可以保证当后面加载到匹配 (置顶) 时, 看到的是那个被匹配到的 + item { Spacer(Modifier.height(1.dp)) } + + for (matchResult in filteredList) { + item(key = matchResult.key) { + val toaster = LocalToaster.current + val clipboard = LocalClipboardManager.current + ListItem( + headlineContent = { + Text( + matchResult.originalUrl, + color = if (matchResult.highlight) + MaterialTheme.colorScheme.primary else Color.Unspecified, + ) + }, + Modifier.animateItem() + .clickable { + clipboard.setText(AnnotatedString(matchResult.originalUrl)) + toaster.toast("已复制") + }, + supportingContent = { + val m3u8 = matchResult.video?.m3u8Url + when { + m3u8 != null && m3u8 != matchResult.originalUrl -> { + Text("将实际播放:${m3u8}") + } + + matchResult.webUrl.didLoadNestedPage -> { + Text("嵌套链接") + } + } + }, + colors = itemColors, + leadingContent = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + when { + matchResult.highlight -> { + Icon(Icons.Rounded.Check, "匹配", tint = MaterialTheme.colorScheme.primary) + } + + else -> { + Icon(Icons.Rounded.Close, "未匹配") + } + } + } + }, + ) + } + } + } + } +} + + +@Serializable +sealed class SelectorEpisodePaneRoutes { + @Serializable + @SerialName("TEST") + data object TEST : SelectorEpisodePaneRoutes() + + @Serializable + @SerialName("EPISODE") // remove package + data object EPISODE : SelectorEpisodePaneRoutes() +} + +@Immutable +data class SelectorEpisodePaneLayout( + val showTopBarInPane: Boolean, + val showTopBarInScaffold: Boolean, + val showBottomSheet: Boolean, +) { + companion object { + val Expanded = SelectorEpisodePaneLayout( + showTopBarInPane = true, + showTopBarInScaffold = false, + showBottomSheet = false, + ) + + val Compact = SelectorEpisodePaneLayout( + showTopBarInPane = false, + showTopBarInScaffold = true, + showBottomSheet = true, + ) + + fun calculate( + scaffoldValue: ThreePaneScaffoldValue, + ): SelectorEpisodePaneLayout { + return when { + scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded -> { + // list 和 extra 同时展开, 也就是大屏环境. list 内包含了配置, 所以我们无需使用 bottom sheet 显示配置 + Expanded + } + + else -> Compact + } + } + } +} + diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePaneDefaults.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePaneDefaults.kt new file mode 100644 index 0000000000..c59fa5e6f8 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodePaneDefaults.kt @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.episode + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowOutward +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults +import me.him188.ani.app.ui.foundation.widgets.LocalToaster +import me.him188.ani.app.ui.foundation.widgets.TopAppBarGoBackButton +import me.him188.ani.app.ui.settings.mediasource.RefreshIndicationDefaults +import me.him188.ani.app.ui.settings.mediasource.selector.edit.MatchVideoSection +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigState +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationDefaults + +object SelectorEpisodePaneDefaults { + @Composable + fun TopAppBar( + state: SelectorEpisodeState, + modifier: Modifier = Modifier.Companion, + windowInsets: WindowInsets = WindowInsets(0.dp), + ) { + val onRefresh = { state.searcher.restartCurrentSearch() } + TopAppBar( + navigationIcon = { + TopAppBarGoBackButton() + }, + title = { + Row( + verticalAlignment = Alignment.Companion.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(state.episodeName, style = LocalTextStyle.current) + RefreshIndicationDefaults.RefreshIconButton( + onClick = onRefresh, + ) + RefreshIndicationDefaults.RefreshResultTextButton( + result = state.searcher.searchResult, + onRefresh = onRefresh, + ) + } + }, + actions = { + val uriHandler = LocalUriHandler.current + val toaster = LocalToaster.current + if (state.episodeUrl.isNotBlank() && state.episodeUrl.startsWith("http")) { + IconButton( + { + try { + uriHandler.openUri(state.episodeUrl) + } catch (e: Throwable) { + toaster.toast("无法打开链接") + } + }, + ) { + Icon(Icons.Rounded.ArrowOutward, "打开原始链接 ${state.episodeName}") + } + } + }, + colors = AniThemeDefaults.topAppBarColors(), + modifier = modifier, + windowInsets = windowInsets, + ) + } + + @Composable + fun ConfigurationContent( + state: SelectorConfigState, + modifier: Modifier = Modifier.Companion, + contentPadding: PaddingValues = PaddingValues(0.dp), + textFieldShape: Shape = SelectorConfigurationDefaults.textFieldShape, + verticalSpacing: Dp = SelectorConfigurationDefaults.verticalSpacing, + ) { + Column(modifier.padding(contentPadding)) { + Row(Modifier.Companion.padding(bottom = 16.dp)) { + ProvideTextStyle( + MaterialTheme.typography.titleLarge, + ) { + Text("编辑配置") + } + } + SelectorConfigurationDefaults.MatchVideoSection( + state, + textFieldShape = textFieldShape, + verticalSpacing = verticalSpacing, + ) + } + } + +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeResult.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeResult.kt new file mode 100644 index 0000000000..765614d5e9 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeResult.kt @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.episode + +import kotlinx.collections.immutable.PersistentList +import kotlinx.coroutines.flow.StateFlow +import me.him188.ani.app.data.models.ApiFailure +import me.him188.ani.app.ui.settings.mediasource.RefreshResult + +sealed class SelectorEpisodeResult : RefreshResult { + data class InProgress( + val flow: StateFlow>, + ) : SelectorEpisodeResult(), RefreshResult.InProgress + + data class Success( + val flow: StateFlow>, + ) : SelectorEpisodeResult(), RefreshResult.Success + + object InvalidConfig : SelectorEpisodeResult(), RefreshResult.InvalidConfig + data class ApiError(override val reason: ApiFailure) : SelectorEpisodeResult(), RefreshResult.ApiError + data class UnknownError(override val exception: Throwable) : SelectorEpisodeResult(), RefreshResult.UnknownError +} + +data class SelectorTestWebUrl( + val url: String, + val didLoadNestedPage: Boolean, +) diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeState.kt new file mode 100644 index 0000000000..e006531b6d --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/episode/SelectorEpisodeState.kt @@ -0,0 +1,199 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.episode + +import androidx.compose.runtime.* +import io.ktor.http.Url +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withTimeoutOrNull +import me.him188.ani.app.data.source.media.resolver.WebViewVideoExtractor +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.platform.Context +import me.him188.ani.app.ui.settings.mediasource.BackgroundSearcher +import me.him188.ani.app.ui.settings.mediasource.launchCollectedInBackground +import me.him188.ani.app.ui.settings.mediasource.selector.test.SelectorTestEpisodePresentation +import me.him188.ani.datasources.api.matcher.WebVideo +import me.him188.ani.datasources.api.matcher.videoOrNull +import me.him188.ani.utils.platform.Uuid +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds + +/** + * 测试 [me.him188.ani.datasources.api.matcher.WebVideoMatcher] + */ +@Stable +class SelectorEpisodeState( + private val itemState: State, + /** + * null means loading. Should finally have one. + */ + matchVideoConfigState: State, + /** + * null means loading. Should finally have one. + */ + private val webViewVideoExtractor: State, + private val engine: SelectorMediaSourceEngine, + backgroundScope: CoroutineScope, + context: Context, + flowDispatcher: CoroutineContext = Dispatchers.Default, +) { + private var _lastNonNullId: Uuid = Uuid.Companion.random() // 当取消选择时, 仍然需要保持 ID, 才能有 container transform 动画 + val lastNonNullId by derivedStateOf { + itemState.value?.id?.also { _lastNonNullId = it } ?: _lastNonNullId + } + + val episodeName: String by derivedStateOf { itemState.value?.name ?: "" } + val episodeUrl: String by derivedStateOf { itemState.value?.playUrl ?: "" } + + /** + * 该页面的所有链接 + */ + val searcher = + BackgroundSearcher( + backgroundScope, + testDataState = derivedStateOf { + Triple(itemState.value?.playUrl, webViewVideoExtractor.value, matchVideoConfigState.value) + }, + ) { (episodeUrl, extractor, config) -> + launchCollectedInBackground( + updateState = { SelectorEpisodeResult.InProgress(it) }, + ) { flow -> + try { + if (episodeUrl != null && extractor != null && config != null) { + withTimeoutOrNull(30.seconds) { // timeout considered as success + extractor.getVideoResourceUrl(context, episodeUrl) { + val shouldLoadPage = engine.shouldLoadPage(it, config) + collect(SelectorTestWebUrl(it, didLoadNestedPage = shouldLoadPage)) + + if (shouldLoadPage) { + WebViewVideoExtractor.Instruction.LoadPage + } else { + WebViewVideoExtractor.Instruction.Continue + } + } + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SelectorEpisodeResult.UnknownError(e) + } + SelectorEpisodeResult.Success(flow) + } + } + + @Immutable + @Stable + data class MatchResult( + val webUrl: SelectorTestWebUrl, + val video: WebVideo?, + ) { + val originalUrl get() = webUrl.url + val parsedUrl = runCatching { Url(originalUrl) }.getOrNull() + + // ui + val key get() = originalUrl + + @Stable + fun isMatchedVideo() = video != null + + val highlight get() = isMatchedVideo() || webUrl.didLoadNestedPage + } + + val isSearchingInProgress get() = searcher.isSearching + + /** + * 不断更新的匹配结果 + */ + val rawMatchResults: Flow> by derivedStateOf { + val matchVideoConfig = matchVideoConfigState.value ?: return@derivedStateOf emptyFlow() + val searchResult = searcher.searchResult ?: return@derivedStateOf emptyFlow() + val flow = when (searchResult) { + is SelectorEpisodeResult.ApiError, + is SelectorEpisodeResult.UnknownError, + is SelectorEpisodeResult.InvalidConfig, + -> return@derivedStateOf emptyFlow() + + is SelectorEpisodeResult.InProgress -> searchResult.flow + is SelectorEpisodeResult.Success -> searchResult.flow + } + + flow.map { list -> + list.asSequence() + .map { original -> + MatchResult(original, engine.matchWebVideo(original.url, matchVideoConfig).videoOrNull) + } + .distinctBy { it.key } // O(n) extra space, O(1) time + .toMutableList() // single list instance construction + .apply { + // sort in-place for better performance + sortByDescending { it.isMatchedVideo() } // 优先展示匹配的 + } + }.flowOn(flowDispatcher) // possibly significant computation + } + + val filteredResults: Flow> by derivedStateOf { + // read states in UI thread + val hideImages = hideImages + val hideCss = hideCss + val hideScripts = hideScripts + val hideData = hideData + + rawMatchResults.map { list -> + // In background + list.filter { shouldIncludeUrl(it, hideImages, hideCss, hideScripts, hideData) } + }.flowOn(flowDispatcher) + } + + var hideImages: Boolean by mutableStateOf(true) + var hideCss: Boolean by mutableStateOf(true) + var hideScripts: Boolean by mutableStateOf(true) + var hideData: Boolean by mutableStateOf(true) + + companion object { + private val imageExtensions = setOf("jpg", "jpeg", "png", "gif", "webp", "ico", "svg") + private val cssExtensions = setOf("css", "ttf", "woff2") + private val scriptsExtensions = setOf("js", "wasm") + + private fun shouldIncludeUrl( + result: MatchResult, + hideImages: Boolean, + hideCss: Boolean, + hideScripts: Boolean, + hideData: Boolean, + ): Boolean { + val lastSegment = result.parsedUrl?.pathSegments?.lastOrNull() ?: return true + val extension = lastSegment.substringAfterLast('.', "") + if (extension.isNotEmpty()) { + if (hideImages) { + if (imageExtensions.any { extension.equals(it, ignoreCase = true) }) return false + } + if (hideCss) { + if (cssExtensions.any { extension.equals(it, ignoreCase = true) }) return false + } + if (hideScripts) { + if (scriptsExtensions.any { extension.equals(it, ignoreCase = true) }) return false + } + } + if (hideData) { + if (result.originalUrl.startsWith("data:")) return false + } + return true + } + } +} + diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt new file mode 100644 index 0000000000..648b0914f1 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectTestEpisodeResult.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.runtime.Immutable +import me.him188.ani.app.data.models.ApiFailure +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.data.source.media.source.web.SelectorSearchQuery +import me.him188.ani.app.data.source.media.source.web.WebSearchEpisodeInfo +import me.him188.ani.app.ui.settings.mediasource.RefreshResult +import me.him188.ani.app.ui.settings.mediasource.rss.test.MatchTag +import me.him188.ani.app.ui.settings.mediasource.rss.test.buildMatchTags +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.platform.Uuid +import me.him188.ani.utils.xml.Element + +@Immutable +sealed class SelectorTestEpisodeListResult : RefreshResult { + @Immutable + data class Success( + val channels: List?, + /** + * must distinct by [SelectorTestEpisodePresentation.playUrl] + */ + val episodes: List, + ) : SelectorTestEpisodeListResult(), RefreshResult.Success + + @Immutable + data class ApiError( + override val reason: ApiFailure + ) : SelectorTestEpisodeListResult(), RefreshResult.ApiError + + @Immutable + data object InvalidConfig : SelectorTestEpisodeListResult(), RefreshResult.InvalidConfig + + @Immutable + data class UnknownError( + override val exception: Throwable + ) : SelectorTestEpisodeListResult(), RefreshResult.UnknownError +} + +@Immutable +class SelectorTestEpisodePresentation( + val name: String, + val episodeSort: EpisodeSort?, + val playUrl: String, + val tags: List, + val origin: Element?, +) { + val id: Uuid = Uuid.random() + + companion object { + fun compute( + info: WebSearchEpisodeInfo, + searchQuery: SelectorSearchQuery, + origin: Element?, + config: SelectorSearchConfig, + ): SelectorTestEpisodePresentation { + return SelectorTestEpisodePresentation( + name = info.name, + episodeSort = info.episodeSort, + playUrl = info.playUrl, + tags = buildMatchTags { + if (config.filterByEpisodeSort) { + if (info.episodeSort == null) { + emit("缺失 EP", isMissing = true) + } else { + emit("EP: ${info.episodeSort}", isMatch = info.episodeSort == searchQuery.episodeSort) + } + } + + when { + info.playUrl.isEmpty() -> { + emit("缺失播放地址", isMissing = true) + } + + !info.playUrl.startsWith("http") -> { + emit("播放地址: ${info.playUrl}", isMatch = false) + } + + else -> { + emit(info.playUrl, isMatch = true) + } + } + }, + origin = origin, + ) + } + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt new file mode 100644 index 0000000000..ab86279d69 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestEpisodeList.kt @@ -0,0 +1,108 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.layout.cardHorizontalPadding +import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding +import me.him188.ani.app.ui.settings.mediasource.rss.test.OutlinedMatchTag + +@Composable +fun SelectorTestEpisodeListGrid( + episodes: List, + modifier: Modifier = Modifier, + state: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + eachItem: @Composable (SelectorTestEpisodePresentation) -> Unit, +) { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Adaptive(300.dp), + modifier, + state, + contentPadding, + horizontalArrangement = Arrangement.spacedBy(currentWindowAdaptiveInfo().windowSizeClass.cardHorizontalPadding), + verticalItemSpacing = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding, + ) { + for (episode in episodes) { + item(key = episode.playUrl) { + eachItem(episode) + } + } + } +} + +@Stable +object SelectorTestEpisodeListGridDefaults { + @Composable + fun EpisodeCard( + episode: SelectorTestEpisodePresentation, + onClick: () -> Unit, + modifier: Modifier = Modifier, + ) { + EpisodeCard( + title = { Text(episode.name) }, + { onClick() }, + modifier, + ) { + episode.tags.forEach { + OutlinedMatchTag(it) + } + } + } + +} + +@Composable +private fun EpisodeCard( + title: @Composable () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tags: @Composable FlowRowScope.() -> Unit, +) { + val color = CardDefaults.cardColors() + Card( + onClick, + modifier, + colors = color, + ) { + ListItem( + headlineContent = title, + supportingContent = { + FlowRow( + Modifier.padding(top = 8.dp, bottom = 8.dp).width(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = tags, + ) + }, + colors = ListItemDefaults.colors(containerColor = color.containerColor), + ) + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt new file mode 100644 index 0000000000..73d220f210 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestPane.kt @@ -0,0 +1,180 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.interaction.nestedScrollWorkaround +import me.him188.ani.app.ui.foundation.layout.ConnectedScrollState +import me.him188.ani.app.ui.foundation.layout.PaddingValuesSides +import me.him188.ani.app.ui.foundation.layout.cardVerticalPadding +import me.him188.ani.app.ui.foundation.layout.connectedScroll +import me.him188.ani.app.ui.foundation.layout.only +import me.him188.ani.app.ui.foundation.layout.rememberConnectedScrollState +import me.him188.ani.app.ui.foundation.theme.AniThemeDefaults +import me.him188.ani.app.ui.foundation.widgets.FastLinearProgressIndicator +import me.him188.ani.app.ui.settings.mediasource.EditMediaSourceTestDataCardDefaults +import me.him188.ani.app.ui.settings.mediasource.RefreshIndicatedHeadlineRow +import me.him188.ani.app.ui.settings.mediasource.selector.edit.SelectorConfigurationDefaults + +/** + * 测试数据源. 编辑 + */ +@Composable +fun SharedTransitionScope.SelectorTestPane( + state: SelectorTestState, + onViewEpisode: (SelectorTestEpisodePresentation) -> Unit, + animatedVisibilityScope: AnimatedVisibilityScope, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + connectedScrollState: ConnectedScrollState = rememberConnectedScrollState(), +) { + val verticalSpacing = currentWindowAdaptiveInfo().windowSizeClass.cardVerticalPadding + Column( + modifier + .padding(contentPadding.only(PaddingValuesSides.Top)) + .clipToBounds(), + ) { + Column( + Modifier.connectedScroll(connectedScrollState) + .padding(contentPadding.only(PaddingValuesSides.Horizontal)), + ) { + Text( + "测试数据源", + style = MaterialTheme.typography.headlineSmall, + ) + + EditTestDataCard( + state, + Modifier + .padding(top = verticalSpacing) + .fillMaxWidth(), + ) + + RefreshIndicatedHeadlineRow( + headline = { Text(SelectorConfigurationDefaults.STEP_NAME_1) }, + onRefresh = { state.subjectSearcher.restartCurrentSearch() }, + result = state.subjectSearchSelectResult, + Modifier.padding(top = verticalSpacing), + ) + + Box(Modifier.height(12.dp), contentAlignment = Alignment.Center) { + FastLinearProgressIndicator( + state.subjectSearcher.isSearching, + delayMillis = 0, + minimumDurationMillis = 300, + ) + } + + AnimatedContent( + state.subjectSearchSelectResult, + transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, + ) { result -> + if (result is SelectorTestSearchSubjectResult.Success) { + SelectorTestSubjectResultLazyRow( + items = result.subjects, + state.selectedSubjectIndex, + onSelect = { index, _ -> + state.selectedSubjectIndex = index + }, + modifier = Modifier.padding(top = verticalSpacing - 8.dp), + ) + } + } + } + + AnimatedContent( + state.selectedSubject, + Modifier.padding(contentPadding.only(PaddingValuesSides.Horizontal)), + transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, + ) { selectedSubjectIndex -> + if (selectedSubjectIndex != null) { + Column { + RefreshIndicatedHeadlineRow( + headline = { Text(SelectorConfigurationDefaults.STEP_NAME_2) }, + onRefresh = { state.episodeListSearcher.restartCurrentSearch() }, + result = state.episodeListSearchSelectResult, + Modifier.padding(top = verticalSpacing), + ) + + Box(Modifier.height(12.dp), contentAlignment = Alignment.Center) { + FastLinearProgressIndicator( + state.episodeListSearcher.isSearching, + delayMillis = 0, + minimumDurationMillis = 300, + ) + } + } + } + } + + if (state.selectedSubject != null) { + AnimatedContent( + state.episodeListSearchSelectResult, + transitionSpec = AniThemeDefaults.standardAnimatedContentTransition, + ) { result -> + if (result is SelectorTestEpisodeListResult.Success) { + val staggeredGridState = rememberLazyStaggeredGridState() + SelectorTestEpisodeListGrid( + result.episodes, + modifier = Modifier.padding(top = verticalSpacing - 8.dp) + .nestedScroll(connectedScrollState.nestedScrollConnection) + .nestedScrollWorkaround(staggeredGridState, connectedScrollState), + state = staggeredGridState, + contentPadding = contentPadding.only(PaddingValuesSides.Horizontal + PaddingValuesSides.Bottom), + ) { episode -> + SelectorTestEpisodeListGridDefaults.EpisodeCard( + episode, + { onViewEpisode(episode) }, + Modifier.sharedBounds(rememberSharedContentState(episode.id), animatedVisibilityScope), + ) + } + } + } + } + } +} + +@Composable +private fun EditTestDataCard( + state: SelectorTestState, + modifier: Modifier = Modifier, +) { + with(EditMediaSourceTestDataCardDefaults) { + Card( + modifier, + shape = cardShape, + colors = cardColors, + ) { + FlowRow { + KeywordTextField(state, Modifier.weight(1f)) + EpisodeSortTextField(state, Modifier.weight(1f)) + } + } + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestResultLazyRow.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestResultLazyRow.kt new file mode 100644 index 0000000000..623f20b11c --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestResultLazyRow.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.layout.cardHorizontalPadding + + +/** + * @see selectedItemIndex `-1` for no selection + */ +@Composable +internal fun SelectorTestResultLazyRow( + items: List, + selectedItemIndex: Int, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + item: @Composable LazyItemScope.(index: Int, T) -> Unit, +) { + require(selectedItemIndex == -1 || selectedItemIndex >= 0) { + "selectedItemIndex must be -1 or in the range of items" + } + val density = LocalDensity.current + val lazyListState = rememberLazyListState() + LaunchedEffect(selectedItemIndex) { + if (selectedItemIndex != -1 && !lazyListState.isItemFullyVisible(selectedItemIndex)) { + // 如果有选择一个目前不可见的项目, 将其滚动到可见区域 + lazyListState.animateScrollToItem( + selectedItemIndex, + // 左边显示一点点上个项目, 这样让他知道左边还有东西 + scrollOffset = -with(density) { 36.dp.roundToPx() }, + ) + } + } + LazyRow( + modifier = modifier, + state = lazyListState, + contentPadding = contentPadding, + horizontalArrangement = Arrangement.spacedBy(currentWindowAdaptiveInfo().windowSizeClass.cardHorizontalPadding), + ) { + for ((index, value) in items.withIndex()) { + item { + item(index, value) + } + } + } +} + +@Stable +private fun LazyListState.isItemFullyVisible(index: Int): Boolean { + val layoutInfo = layoutInfo + val item = layoutInfo.visibleItemsInfo.find { it.index == index } + ?: return false + if (item.offset < 0) return false + + // Check if the item is fully visible + return item.offset + item.size <= layoutInfo.viewportEndOffset +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt new file mode 100644 index 0000000000..6f54bfc5ae --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestSearchSubjectResult.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.runtime.Immutable +import me.him188.ani.app.data.models.ApiFailure +import me.him188.ani.app.data.source.media.source.MediaListFilters +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.data.source.media.source.web.SelectorSearchQuery +import me.him188.ani.app.data.source.media.source.web.WebSearchSubjectInfo +import me.him188.ani.app.data.source.media.source.web.asCandidate +import me.him188.ani.app.data.source.media.source.web.toFilterContext +import me.him188.ani.app.ui.settings.mediasource.RefreshResult +import me.him188.ani.app.ui.settings.mediasource.rss.test.MatchTag +import me.him188.ani.app.ui.settings.mediasource.rss.test.buildMatchTags +import me.him188.ani.utils.xml.Element + +// For UI +@Immutable +sealed class SelectorTestSearchSubjectResult : RefreshResult { + @Immutable + data class Success( + val encodedUrl: String, + val subjects: List, + ) : SelectorTestSearchSubjectResult(), RefreshResult.Success + + @Immutable + data class ApiError( + override val reason: ApiFailure, + ) : SelectorTestSearchSubjectResult(), RefreshResult.ApiError + + @Immutable + data object InvalidConfig : SelectorTestSearchSubjectResult(), RefreshResult.InvalidConfig + + @Immutable + data class UnknownError( + override val exception: Throwable, + ) : SelectorTestSearchSubjectResult(), RefreshResult.UnknownError +} + +@Immutable +data class SelectorTestSubjectPresentation( + val name: String, + val subjectDetailsPageUrl: String, + val origin: Element?, + val tags: List, +) { + companion object { + fun compute( + info: WebSearchSubjectInfo, + query: SelectorSearchQuery, + origin: Element?, + config: SelectorSearchConfig, + ): SelectorTestSubjectPresentation { + val tags = computeTags(info, query, config) + return SelectorTestSubjectPresentation( + name = info.name, + subjectDetailsPageUrl = info.subjectDetailsPageUrl, + origin = origin, + tags = tags, + ) + } + + private fun computeTags( + info: WebSearchSubjectInfo, + query: SelectorSearchQuery, + config: SelectorSearchConfig, + ) = buildMatchTags { + with(query.toFilterContext()) { + val candidate = info.asCandidate() + if (config.filterBySubjectName) { + if (!MediaListFilters.ContainsSubjectName.applyOn(candidate)) { + emit("标题", isMatch = false) + } else { + emit("标题", isMatch = true) + } + } + } + } + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt new file mode 100644 index 0000000000..7dc2f5e644 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SelectorTestState.kt @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.util.fastDistinctBy +import kotlinx.coroutines.CoroutineScope +import me.him188.ani.app.data.models.ApiResponse +import me.him188.ani.app.data.models.fold +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSourceEngine +import me.him188.ani.app.data.source.media.source.web.SelectorSearchConfig +import me.him188.ani.app.data.source.media.source.web.SelectorSearchQuery +import me.him188.ani.app.ui.settings.mediasource.AbstractMediaSourceTestState +import me.him188.ani.app.ui.settings.mediasource.BackgroundSearcher +import me.him188.ani.datasources.api.EpisodeSort +import me.him188.ani.utils.xml.Document +import kotlin.coroutines.cancellation.CancellationException + +@Stable +class SelectorTestState( + searchConfigState: State, + private val engine: SelectorMediaSourceEngine, + backgroundScope: CoroutineScope, +) : AbstractMediaSourceTestState() { + // null for invalid config + private val queryState = derivedStateOf { + val searchKeyword = searchKeyword.ifEmpty { searchKeywordPlaceholder } + val sort = sort + if (searchKeyword.isBlank() || sort.isBlank()) { + null + } else { + SelectorSearchQuery( + subjectName = searchKeyword, + episodeSort = EpisodeSort(sort), + allSubjectNames = setOf(searchKeyword), + ) + } + } + + var selectedSubjectIndex by mutableIntStateOf(0) + val selectedSubjectState = derivedStateOf { + val success = subjectSearchSelectResult as? SelectorTestSearchSubjectResult.Success + ?: return@derivedStateOf null + success.subjects.getOrNull(selectedSubjectIndex) + } + val selectedSubject by selectedSubjectState + private val searchUrl by derivedStateOf { + searchConfigState.value?.searchUrl + } + private val useOnlyFirstWord by derivedStateOf { + searchConfigState.value?.searchUseOnlyFirstWord + } + + /** + * 用于查询条目列表, 每当编辑请求和 `searchUrl`, 会重新搜索, 但不会筛选. + * 筛选在 [subjectSearchSelectResult]. + */ + val subjectSearcher = BackgroundSearcher( + backgroundScope, + derivedStateOf { + Triple( + searchConfigState.value?.searchUrl, + searchKeyword, + searchConfigState.value?.searchUseOnlyFirstWord, + ) + }, + search = { (url, searchKeyword, useOnlyFirstWord) -> + // 不清除 selectedSubjectIndex + + launchRequestInBackground { + if (url == null || url.isBlank() || searchKeyword.isBlank() || useOnlyFirstWord == null) { + null + } else { + try { + val res = engine.searchSubjects( + searchUrl = url, + searchKeyword, + useOnlyFirstWord = useOnlyFirstWord, + ) + Result.success(res) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } + } + } + }, + ) + + val subjectSearchSelectResult by derivedStateOf { + val res = subjectSearcher.searchResult + val config = searchConfigState.value + val query = queryState.value + when { + res == null -> { + null + } + + config == null || query == null -> { + SelectorTestSearchSubjectResult.InvalidConfig + } + + else -> { + res.fold( + onSuccess = { + selectSubjectResult(it, config, query) + }, + onFailure = { + SelectorTestSearchSubjectResult.UnknownError(it) + }, + ) + } + } + } + + /** + * 用于查询条目的剧集列表, 每当选择新的条目时, 会重新搜索. 但不会筛选. 筛选在 [episodeListSearchSelectResult]. + */ + val episodeListSearcher = BackgroundSearcher( + backgroundScope, + selectedSubjectState, + search = { selectedSubject -> + launchRequestInBackground { + if (selectedSubject == null) { + null + } else { + try { + Result.success( + engine.searchEpisodes( + selectedSubject.subjectDetailsPageUrl, + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } + } + } + }, + ) + + /** + * 经过筛选的条目的剧集列表 + */ + val episodeListSearchSelectResult by derivedStateOf { + val subjectDetailsPageDocument = episodeListSearcher.searchResult + val searchConfig = searchConfigState.value + val queryState = queryState.value + + when { + queryState == null || searchConfig == null -> { + SelectorTestEpisodeListResult.InvalidConfig + } + + subjectDetailsPageDocument == null -> { + SelectorTestEpisodeListResult.Success(null, emptyList()) + } + + else -> { + subjectDetailsPageDocument.fold( + onSuccess = { document -> + convertEpisodeResult(document, searchConfig, queryState) + }, + onFailure = { + SelectorTestEpisodeListResult.UnknownError(it) + }, + ) + } + } + } + + private fun convertEpisodeResult( + res: ApiResponse, + config: SelectorSearchConfig, + query: SelectorSearchQuery, + ): SelectorTestEpisodeListResult { + return res.fold( + onSuccess = { document -> + try { + document ?: return SelectorTestEpisodeListResult.Success(null, emptyList()) + + val episodeList = engine.selectEpisodes(document, config) + ?: return SelectorTestEpisodeListResult.InvalidConfig + SelectorTestEpisodeListResult.Success( + episodeList.channels, + episodeList.episodes + .fastDistinctBy { it.playUrl } + .map { + SelectorTestEpisodePresentation.compute(it, query, document, config) + }, + ) + } catch (e: Throwable) { + SelectorTestEpisodeListResult.UnknownError(e) + } + }, + onKnownFailure = { reason -> + SelectorTestEpisodeListResult.ApiError(reason) + }, + ) + } + + private fun selectSubjectResult( + res: ApiResponse, + searchConfig: SelectorSearchConfig, + query: SelectorSearchQuery, + ): SelectorTestSearchSubjectResult { + return res.fold( + onSuccess = { data -> + val document = data.document + + val originalList = if (document == null) { + emptyList() + } else { + engine.selectSubjects(document, searchConfig).let { + if (it == null) { + return SelectorTestSearchSubjectResult.InvalidConfig + } + it + } + } + + SelectorTestSearchSubjectResult.Success( + data.url.toString(), + originalList.map { + SelectorTestSubjectPresentation.compute(it, query, document, searchConfig) + }, + ) + }, + onKnownFailure = { reason -> + SelectorTestSearchSubjectResult.ApiError(reason) + }, + ) + } +} \ No newline at end of file diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.kt new file mode 100644 index 0000000000..6703053e99 --- /dev/null +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/mediasource/selector/test/SubjectResultLazyRow.kt @@ -0,0 +1,135 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + +package me.him188.ani.app.ui.settings.mediasource.selector.test + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import me.him188.ani.app.ui.foundation.Tag +import me.him188.ani.app.ui.settings.mediasource.rss.test.OutlinedMatchTag + +/** + * @see selectedItemIndex `-1` for no selection + */ +@Composable +internal fun SelectorTestSubjectResultLazyRow( + items: List, + selectedItemIndex: Int, + onSelect: (Int, SelectorTestSubjectPresentation) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), +) { + SubcomposeLayout { constraints -> + // measure 一个卡片的高度 + val (measurable) = subcompose(0) { + SelectorTestResultCard( + title = { Text("1\n2") }, + isSelected = false, + onClick = {}, + tags = { Tag { Text("Dummy") } }, + ) + } + val itemSize = measurable.measure(constraints) + + val (lazyRow) = subcompose(1) { + SelectorTestResultLazyRow(items, selectedItemIndex, modifier, contentPadding) { index, item -> + SelectorTestSubjectResultCard( + item, + selectedItemIndex == index, + onClick = { onSelect(index, item) }, + Modifier.height(itemSize.height.toDp()), // 使用固定高度 + ) + } + } + + val lazyRowPlaceable = lazyRow.measure(constraints) + layout(lazyRowPlaceable.width, lazyRowPlaceable.height) { + lazyRowPlaceable.place(0, 0) + } + } +} + +@Composable +internal fun SelectorTestSubjectResultCard( + item: SelectorTestSubjectPresentation, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + SelectorTestResultCard( + title = { + Text( + item.name, + Modifier.width(IntrinsicSize.Max), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + }, + isSelected = isSelected, + onClick = onClick, + modifier + .width(IntrinsicSize.Min) // 有 widthIn max 之后不知道为什么它就会默认 fillMaxWidth + .widthIn(min = 120.dp, max = 240.dp), + tags = { + for (tag in item.tags) { + OutlinedMatchTag(tag) + } + }, + ) +} + + +@Composable +internal fun SelectorTestResultCard( + title: @Composable () -> Unit, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + tags: @Composable FlowRowScope.() -> Unit, +) { + val color = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.secondaryContainer + else CardDefaults.cardColors().containerColor, + ) + Card( + onClick, + modifier, colors = color, + ) { + ListItem( + headlineContent = title, + supportingContent = { + FlowRow( + Modifier.padding(top = 8.dp, bottom = 8.dp).width(IntrinsicSize.Max), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + content = tags, + ) + }, + colors = ListItemDefaults.colors(containerColor = color.containerColor), + ) + } +} diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt index 33a5126054..11bb942c59 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceGroup.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.app.ui.settings.tabs.media.source import androidx.compose.animation.AnimatedVisibility @@ -44,6 +53,7 @@ import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -59,6 +69,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import me.him188.ani.app.data.source.media.source.RssMediaSource +import me.him188.ani.app.data.source.media.source.web.SelectorMediaSource import me.him188.ani.app.navigation.LocalNavigator import me.him188.ani.app.ui.foundation.ifThen import me.him188.ani.app.ui.settings.framework.ConnectionTesterResultIndicator @@ -73,8 +84,10 @@ import org.burnoutcrew.reorderable.detectReorder import org.burnoutcrew.reorderable.detectReorderAfterLongPress import org.burnoutcrew.reorderable.reorderable +@Stable internal val MediaSourcesUsingNewSettings = listOf( RssMediaSource.FactoryId, + SelectorMediaSource.FactoryId, ) @Composable diff --git a/datasource/api/src/commonMain/kotlin/matcher/WebVideoMatcher.kt b/datasource/api/src/commonMain/kotlin/matcher/WebVideoMatcher.kt index 785808cf09..fb1618abf4 100644 --- a/datasource/api/src/commonMain/kotlin/matcher/WebVideoMatcher.kt +++ b/datasource/api/src/commonMain/kotlin/matcher/WebVideoMatcher.kt @@ -16,14 +16,25 @@ import me.him188.ani.datasources.api.source.MediaSource /** * 匹配 WebView 拦截到的资源. - */ + */ // see also: SelectorMediaSource fun interface WebVideoMatcher { // SPI service load + sealed class MatchResult { + data class Matched( + val video: WebVideo + ) : MatchResult() + + data object Continue : MatchResult() + data object LoadPage : MatchResult() + } + fun match( url: String, context: WebVideoMatcherContext - ): WebVideo? + ): MatchResult } +val WebVideoMatcher.MatchResult.videoOrNull get() = (this as? WebVideoMatcher.MatchResult.Matched)?.video + class WebVideoMatcherContext( val media: Media, // requestInfoLazy: () -> WebVideoRequestInfo, diff --git a/datasource/web/gugufan/src/GugufanMediaSource.kt b/datasource/web/gugufan/src/GugufanMediaSource.kt index bbccff208f..7c9c904842 100644 --- a/datasource/web/gugufan/src/GugufanMediaSource.kt +++ b/datasource/web/gugufan/src/GugufanMediaSource.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.datasources.ntdm import io.ktor.client.plugins.BrowserUserAgent @@ -19,24 +28,26 @@ import me.him188.ani.datasources.api.source.useHttpClient import org.jsoup.nodes.Document class GugufanWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != GugufanMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != GugufanMediaSource.ID) return WebVideoMatcher.MatchResult.Continue // https://fuckjapan.cindiwhite.com/videos/202305/05/64557f4f852ee3050d99fc8c/e8210b/index.m3u8?counts=1×tamp=1721999625000&key=2a2094d5753ae1d26e1332fac72b9db9 if (url.startsWith("https://fuckjapan.cindiwhite.com") && url.contains("index.m3u8")) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "Sec-Ch-Ua-Mobile" to "?0", - "Sec-Ch-Ua-Platform" to "macOS", - "Sec-Fetch-Dest" to "video", - "Sec-Fetch-Mode" to "no-cors", - "Sec-Fetch-Site" to "cross-site", - "Origin" to "https://a79.yizhoushi.com", + return WebVideoMatcher.MatchResult.Matched( + WebVideo( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "Sec-Ch-Ua-Mobile" to "?0", + "Sec-Ch-Ua-Platform" to "macOS", + "Sec-Fetch-Dest" to "video", + "Sec-Fetch-Mode" to "no-cors", + "Sec-Fetch-Site" to "cross-site", + "Origin" to "https://a79.yizhoushi.com", + ), ), ) } - return null + return WebVideoMatcher.MatchResult.Continue } } diff --git a/datasource/web/mxdongman/src/MxdongmanMediaSource.kt b/datasource/web/mxdongman/src/MxdongmanMediaSource.kt index d00cb35bbb..7617eead10 100644 --- a/datasource/web/mxdongman/src/MxdongmanMediaSource.kt +++ b/datasource/web/mxdongman/src/MxdongmanMediaSource.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.datasources.mxdongman import io.ktor.client.plugins.BrowserUserAgent @@ -18,23 +27,25 @@ import me.him188.ani.datasources.api.source.useHttpClient import org.jsoup.nodes.Document class MxdongmanWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != MxdongmanMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != MxdongmanMediaSource.ID) return WebVideoMatcher.MatchResult.Continue if (url.contains("https://v16m-default.akamaized.net")) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "Referer" to "https://www.mxdm4.com/dongmanplay/", - "Sec-Ch-Ua-Mobile" to "?0", - "Sec-Ch-Ua-Platform" to "macOS", - "Sec-Fetch-Dest" to "video", - "Sec-Fetch-Mode" to "no-cors", - "Sec-Fetch-Site" to "cross-site", + return WebVideoMatcher.MatchResult.Matched( + WebVideo( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "Referer" to "https://www.mxdm4.com/dongmanplay/", + "Sec-Ch-Ua-Mobile" to "?0", + "Sec-Ch-Ua-Platform" to "macOS", + "Sec-Fetch-Dest" to "video", + "Sec-Fetch-Mode" to "no-cors", + "Sec-Fetch-Site" to "cross-site", + ), ), ) } - return null + return WebVideoMatcher.MatchResult.Continue } } diff --git a/datasource/web/ntdm/src/NtdmMediaSource.kt b/datasource/web/ntdm/src/NtdmMediaSource.kt index f368741349..b244308de5 100644 --- a/datasource/web/ntdm/src/NtdmMediaSource.kt +++ b/datasource/web/ntdm/src/NtdmMediaSource.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.datasources.ntdm import io.ktor.client.plugins.BrowserUserAgent @@ -19,22 +28,24 @@ import me.him188.ani.datasources.api.source.useHttpClient import org.jsoup.nodes.Document class NtdmWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != NtdmMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != NtdmMediaSource.ID) return WebVideoMatcher.MatchResult.Continue if (url.contains(".akamaized.net")) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "Sec-Ch-Ua-Mobile" to "?0", - "Sec-Ch-Ua-Platform" to "macOS", - "Sec-Fetch-Dest" to "video", - "Sec-Fetch-Mode" to "no-cors", - "Sec-Fetch-Site" to "cross-site", + return WebVideoMatcher.MatchResult.Matched( + WebVideo( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "Sec-Ch-Ua-Mobile" to "?0", + "Sec-Ch-Ua-Platform" to "macOS", + "Sec-Fetch-Dest" to "video", + "Sec-Fetch-Mode" to "no-cors", + "Sec-Fetch-Site" to "cross-site", + ), ), ) } - return null + return WebVideoMatcher.MatchResult.Continue } } diff --git a/datasource/web/nyafun/src/NyafunMediaSource.kt b/datasource/web/nyafun/src/NyafunMediaSource.kt index 1b69c50b95..c94806d0a2 100644 --- a/datasource/web/nyafun/src/NyafunMediaSource.kt +++ b/datasource/web/nyafun/src/NyafunMediaSource.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.datasources.nyafun import io.ktor.client.plugins.BrowserUserAgent @@ -57,26 +66,28 @@ data class NyafunEp( ) class NyafunWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != NyafunMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != NyafunMediaSource.ID) return WebVideoMatcher.MatchResult.Continue // we want https://vod.2bdm.cc/2024/04/gs8h/01.mp4?verify=1716675316-p3ScUWwQbHmMf5%2F63tM6%2FR2Ac8NydzYvECQ1XmTUhbU%3D if ((url.contains(".mp4") || url.contains(".mkv") || url.contains(".m3u8")) && url.contains("verify=") ) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "Referer" to "https://play.nyafun.net/", - "Sec-Ch-Ua-Mobile" to "?0", - "Sec-Ch-Ua-Platform" to "macOS", - "Sec-Fetch-Dest" to "video", - "Sec-Fetch-Mode" to "no-cors", - "Sec-Fetch-Site" to "cross-site", + return WebVideoMatcher.MatchResult.Matched( + WebVideo( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "Referer" to "https://play.nyafun.net/", + "Sec-Ch-Ua-Mobile" to "?0", + "Sec-Ch-Ua-Platform" to "macOS", + "Sec-Fetch-Dest" to "video", + "Sec-Fetch-Mode" to "no-cors", + "Sec-Fetch-Site" to "cross-site", + ), ), ) } - return null + return WebVideoMatcher.MatchResult.Continue } } diff --git a/datasource/web/xfdm/src/XfdmMediaSource.kt b/datasource/web/xfdm/src/XfdmMediaSource.kt index d4fbb3b41c..eaf754be1a 100644 --- a/datasource/web/xfdm/src/XfdmMediaSource.kt +++ b/datasource/web/xfdm/src/XfdmMediaSource.kt @@ -1,3 +1,12 @@ +/* + * Copyright (C) 2024 OpenAni and contributors. + * + * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证. + * Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link. + * + * https://github.com/open-ani/ani/blob/main/LICENSE + */ + package me.him188.ani.datasources.ntdm import io.ktor.client.plugins.BrowserUserAgent @@ -19,27 +28,29 @@ import me.him188.ani.datasources.api.source.useHttpClient import org.jsoup.nodes.Document class XfdmWebVideoMatcher : WebVideoMatcher { - override fun match(url: String, context: WebVideoMatcherContext): WebVideo? { - if (context.media.mediaSourceId != XfdmMediaSource.ID) return null + override fun match(url: String, context: WebVideoMatcherContext): WebVideoMatcher.MatchResult { + if (context.media.mediaSourceId != XfdmMediaSource.ID) return WebVideoMatcher.MatchResult.Continue if (url.indexOf("https://", startIndex = 1) != -1) { // 有多个 https - return null + return WebVideoMatcher.MatchResult.Continue } if (url.startsWith("pan.wo.cn") && url.contains("download") || url.contains(".mp4")) { - return WebVideo( - url, - mapOf( - "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", - "Sec-Ch-Ua-Mobile" to "?0", - "Sec-Ch-Ua-Platform" to "macOS", - "Sec-Fetch-Dest" to "video", - "Sec-Fetch-Mode" to "no-cors", - "Sec-Fetch-Site" to "cross-site", + return WebVideoMatcher.MatchResult.Matched( + WebVideo( + url, + mapOf( + "User-Agent" to """Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3""", + "Sec-Ch-Ua-Mobile" to "?0", + "Sec-Ch-Ua-Platform" to "macOS", + "Sec-Fetch-Dest" to "video", + "Sec-Fetch-Mode" to "no-cors", + "Sec-Fetch-Site" to "cross-site", + ), ), ) } - return null + return WebVideoMatcher.MatchResult.Continue } }