Skip to content

Commit

Permalink
WebViewVideoExtractor: support more nested loading and headless options
Browse files Browse the repository at this point in the history
  • Loading branch information
Him188 committed Sep 23, 2024
1 parent df8d194 commit 4d00df5
Show file tree
Hide file tree
Showing 12 changed files with 480 additions and 290 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,75 +69,101 @@ 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>()
resourceMatcher: (String) -> Instruction,
): WebResource? {
val deferred = CompletableDeferred<WebResource>()
withContext(Dispatchers.Main) {
val webView = WebView(context)
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? {
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 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
}
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)),
)

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)
}
}
return false
}
return super.shouldInterceptRequest(view, request)
}
}
webView.loadUrl(pageUrl)

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 {
Expand All @@ -145,4 +175,47 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor {
throw e
}
}

@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)
}

override fun onLoadResource(view: WebView, url: String) {
if (handleUrl(view, url)) {
logger.info { "Found video resource via onLoadResource: $url" }
}
super.onLoadResource(view, url)
}
}
}
}
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
@@ -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,36 @@ 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,
Expand All @@ -31,13 +50,15 @@ expect fun WebViewVideoExtractor(
class TestWebViewVideoExtractor(
private val urls: (pageUrl: String) -> List<String>,
) : WebViewVideoExtractor {
override suspend fun <R : Any> getVideoResourceUrl(
override suspend fun getVideoResourceUrl(
context: Context,
pageUrl: String,
resourceMatcher: (String) -> R?
): R {
resourceMatcher: (String) -> Instruction,
): WebResource {
urls(pageUrl).forEach {
resourceMatcher(it)?.let { return it }
if (resourceMatcher(it) is Instruction.FoundResource) {
return WebResource(it)
}
}
throw IllegalStateException("No match found")
}
Expand Down
Loading

0 comments on commit 4d00df5

Please sign in to comment.