Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SelectorMediaSource & UI #977

Merged
merged 11 commits into from
Sep 23, 2024
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
Loading