Skip to content

Commit

Permalink
Add SelectorMediaSource & UI (#977)
Browse files Browse the repository at this point in the history
* Add SelectorMediaSource

* Add settings ui for SelectorMediaSource

* Extract SElectorConfigurationState and SelectorConfigurationDefaults to separate files

* Extract SelectorTestState to separate file

* Move SelectorEpisodePane.kt

* Implement episode pane

* Fix bugs

* WebViewVideoExtractor: support more nested loading and headless options

* fix matching video

* fix loading
  • Loading branch information
Him188 authored Sep 23, 2024
1 parent 85ff322 commit d16aef7
Show file tree
Hide file tree
Showing 45 changed files with 4,750 additions and 294 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,21 @@ 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
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]
Expand Down Expand Up @@ -65,84 +69,155 @@ 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)
}
}

class AndroidWebViewVideoExtractor : WebViewVideoExtractor {
private companion object {
private val logger = logger<AndroidWebViewVideoExtractor>()
private val consoleMessageUrlRegex = Regex("""'https?://.*?'""")
}

@OptIn(DelicateCoroutinesApi::class)
@SuppressLint("SetJavaScriptEnabled")
override suspend fun <R : Any> getVideoResourceUrl(
override suspend fun getVideoResourceUrl(
context: Context,
pageUrl: String,
resourceMatcher: (String) -> R?,
): R {
val deferred = CompletableDeferred<R>()
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<WebResource>()
val loadedNestedUrls = ConcurrentSkipListSet<String>()

/**
* @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<WebResource>,
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
}
}
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -80,6 +89,11 @@ enum class ResolutionFailures {

NETWORK_ERROR,

/**
* Web 没有匹配到资源
*/
NO_MATCHING_RESOURCE,

/**
* 引擎自身错误 (bug)
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <R : Any> 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<String>,
) : 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")
}
}
Loading

0 comments on commit d16aef7

Please sign in to comment.