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..0e3c00c260 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,54 +106,64 @@ 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() + resourceMatcher: (String) -> Instruction, + ): WebResource? { + val deferred = CompletableDeferred() 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() + + /** + * @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 { @@ -145,4 +175,47 @@ class AndroidWebViewVideoExtractor : WebViewVideoExtractor { throw e } } + + @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) + } + + override fun onLoadResource(view: WebView, url: String) { + if (handleUrl(view, url)) { + logger.info { "Found video resource via onLoadResource: $url" } + } + super.onLoadResource(view, url) + } + } + } } 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/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 a24c4d1417..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,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 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, @@ -31,13 +50,15 @@ expect fun WebViewVideoExtractor( class TestWebViewVideoExtractor( private val urls: (pageUrl: String) -> List, ) : WebViewVideoExtractor { - override suspend fun 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") } 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/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/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 } }