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 0e3c00c260..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 @@ -116,8 +116,10 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor { pageUrl: String, resourceMatcher: (String) -> Instruction, ): WebResource? { - val deferred = CompletableDeferred() - withContext(Dispatchers.Main) { + // WebView requires same thread +// Executors.newSingleThreadExecutor().asCoroutineDispatcher().use { dispatcher -> + return withContext(Dispatchers.Main) { + val deferred = CompletableDeferred() val loadedNestedUrls = ConcurrentSkipListSet() /** @@ -134,14 +136,13 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor { Instruction.LoadPage -> { logger.info { "WebView loading nested page: $url" } - launch { - withContext(Dispatchers.Main) { - @Suppress("LABEL_NAME_CLASH") - if (webView.url == url) return@withContext // avoid infinite loop - if (!loadedNestedUrls.add(url)) return@withContext - logger.info { "New webview created" } - createWebView(context, deferred, ::handleUrl).loadUrl(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 } @@ -151,29 +152,30 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor { 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 -// } -// } - } - - return try { - deferred.await() - } catch (e: Throwable) { - if (deferred.isActive) { - deferred.cancel() + // 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 } - throw e } +// } } @SuppressLint("SetJavaScriptEnabled") 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 index 4fbcad4f2f..ff6ca4cec5 100644 --- 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 @@ -136,7 +136,11 @@ class SelectorMediaSource( query: SelectorSearchQuery, mediaSourceId: String, ): ApiResponse> { - return searchSubjects(searchConfig.searchUrl, query.subjectName).map { (_, document) -> + return searchSubjects( + searchConfig.searchUrl, + subjectName = query.subjectName, + useOnlyFirstWord = searchConfig.searchUseOnlyFirstWord, + ).map { (_, document) -> document ?: return@map emptyList() val episodes = selectSubjects(document, searchConfig) .orEmpty() 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 index 73374a2ece..a16ee13994 100644 --- 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 @@ -83,8 +83,11 @@ abstract class SelectorMediaSourceEngine { suspend fun searchSubjects( searchUrl: String, subjectName: String, + useOnlyFirstWord: Boolean, ): ApiResponse { - val encodedUrl = MediaSourceEngineHelpers.encodeUrlSegment(subjectName) + val encodedUrl = MediaSourceEngineHelpers.encodeUrlSegment( + if (useOnlyFirstWord) getFirstWord(subjectName) else subjectName, + ) val finalUrl = Url( searchUrl.replace("{keyword}", encodedUrl), @@ -93,6 +96,11 @@ abstract class SelectorMediaSourceEngine { 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 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 index bc68dcf409..45c7132c5d 100644 --- 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 @@ -28,6 +28,8 @@ import org.intellij.lang.annotations.Language 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(), 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 index ab90e9a4dc..7bf241b415 100644 --- 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 @@ -53,6 +53,7 @@ data object SelectorSubjectFormatA : SelectorSubjectFormat? { val selectLists = QueryParser.parseSelectorOrNull(config.selectLists) ?: return null - return document.select(selectLists).map { a -> + 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("/") @@ -75,6 +77,12 @@ data object SelectorSubjectFormatA : SelectorSubjectFormat + info.name.length + } + } } } } 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 index 6f5e6f2d36..3e5c94663a 100644 --- 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 @@ -90,7 +90,6 @@ class EditSelectorMediaSourcePageState( if (episodeNavController.currentDestination?.hasRoute() != true) { episodeNavController.navigate(SelectorEpisodePaneRoutes.EPISODE) } - episodeNavController.navigate(SelectorEpisodePaneRoutes.EPISODE) } fun stopViewing() { 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 index 8b564891a2..188d9649e5 100644 --- 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 @@ -36,22 +36,28 @@ class SelectorConfigState( 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() @@ -77,6 +83,7 @@ class SelectorConfigState( val selectListsIsError by derivedStateOf { QueryParser.parseSelectorOrNull(selectLists) == null } + var preferShorterName by prop({ it.preferShorterName }, { copy(preferShorterName = it) }) } // endregion 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 index 5faa425f19..932781ae79 100644 --- 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 @@ -64,7 +64,7 @@ internal fun SelectorConfigurationDefaults.MatchVideoSection( Modifier .padding(bottom = (verticalSpacing - 8.dp).coerceAtLeast(0.dp)) .clickable { matchVideoConfig.enableNestedUrl = !matchVideoConfig.enableNestedUrl }, - supportingContent = { Text("当遇到匹配的链接时,跳转到该链接,并继续匹配视频链接。支持任意次数嵌套") }, + supportingContent = { Text("当遇到匹配的链接时,终止父页面加载并跳转到匹配的链接,在嵌套页面中继续查找视频链接。支持任意次数嵌套") }, trailingContent = { Switch(matchVideoConfig.enableNestedUrl, { matchVideoConfig.enableNestedUrl = it }) }, @@ -79,7 +79,7 @@ internal fun SelectorConfigurationDefaults.MatchVideoSection( .moveFocusOnEnter() .padding(bottom = verticalSpacing), label = { Text("匹配嵌套链接") }, - supportingText = { Text("从播放页面中加载的所有资源链接中匹配出需要跳转进入的链接。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, + supportingText = { Text("正则表达式,从播放页面中加载的所有资源链接中匹配出需要跳转进入的链接。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), shape = textFieldShape, isError = matchVideoConfig.matchNestedUrlIsError, @@ -90,7 +90,7 @@ internal fun SelectorConfigurationDefaults.MatchVideoSection( matchVideoConfig.matchVideoUrl, { matchVideoConfig.matchVideoUrl = it }, Modifier.fillMaxWidth().moveFocusOnEnter(), label = { Text("匹配视频链接") }, - supportingText = { Text("从播放页面中加载的所有资源链接中匹配出视频链接的正则表达式。若正则包含名为 v 的分组则使用该分组,否则使用整个 URL") }, + 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 index 95da9f20a2..89d8673ccc 100644 --- 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 @@ -107,7 +107,7 @@ internal fun SelectorConfigurationPane( } } - Column(verticalArrangement = Arrangement.spacedBy(verticalSpacing)) { + Column { OutlinedTextField( state.searchUrl, { state.searchUrl = it }, Modifier.fillMaxWidth().moveFocusOnEnter(), @@ -130,16 +130,39 @@ internal fun SelectorConfigurationPane( 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(), + 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)) { 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 index 4c7e418f5b..7dc2f5e644 100644 --- 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 @@ -59,6 +59,9 @@ class SelectorTestState( private val searchUrl by derivedStateOf { searchConfigState.value?.searchUrl } + private val useOnlyFirstWord by derivedStateOf { + searchConfigState.value?.searchUseOnlyFirstWord + } /** * 用于查询条目列表, 每当编辑请求和 `searchUrl`, 会重新搜索, 但不会筛选. @@ -67,20 +70,24 @@ class SelectorTestState( val subjectSearcher = BackgroundSearcher( backgroundScope, derivedStateOf { - val url = searchUrl - url to searchKeyword + Triple( + searchConfigState.value?.searchUrl, + searchKeyword, + searchConfigState.value?.searchUseOnlyFirstWord, + ) }, - search = { (url, searchKeyword) -> + search = { (url, searchKeyword, useOnlyFirstWord) -> // 不清除 selectedSubjectIndex launchRequestInBackground { - if (url.isNullOrBlank() || searchKeyword.isBlank()) { + if (url == null || url.isBlank() || searchKeyword.isBlank() || useOnlyFirstWord == null) { null } else { try { val res = engine.searchSubjects( - url, + searchUrl = url, searchKeyword, + useOnlyFirstWord = useOnlyFirstWord, ) Result.success(res) } catch (e: CancellationException) {