23:59
23:59
From 0f6de6f83f4cecd14d24a151887c7ced2c2f7265 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Wed, 29 Apr 2026 16:53:48 +0000
Subject: [PATCH 5/5] Fix search challenge playback and trailers
---
.../data/scraper/Anime3rbExtractor.kt | 35 +++++++---
.../data/scraper/Anime3rbHtmlParser.kt | 13 +++-
.../anistream/domain/model/AnimeModels.kt | 6 ++
.../presentation/details/DetailsScreen.kt | 4 +-
.../navigation/AniStreamNavGraph.kt | 14 ++++
.../presentation/navigation/Routes.kt | 6 ++
.../presentation/player/PlayerScreen.kt | 7 +-
.../presentation/trailer/TrailerScreen.kt | 64 +++++++++++++++++++
.../data/scraper/Anime3rbHtmlParserTest.kt | 3 +-
9 files changed, 132 insertions(+), 20 deletions(-)
create mode 100644 app/src/main/java/com/exapps/anistream/presentation/trailer/TrailerScreen.kt
diff --git a/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt
index 2fab2d6..bc6813b 100644
--- a/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt
+++ b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbExtractor.kt
@@ -1,6 +1,9 @@
package com.exapps.anistream.data.scraper
import com.exapps.anistream.core.common.DispatcherProvider
+import com.exapps.anistream.core.network.CloudflareChallengeDetector
+import com.exapps.anistream.core.webview.Anime3rbSessionBridge
+import com.exapps.anistream.core.webview.VisibleCloudflareChallengeSolver
import com.exapps.anistream.domain.model.AnimeDetails
import com.exapps.anistream.domain.model.CatalogCategory
import com.exapps.anistream.domain.model.CatalogFilters
@@ -16,6 +19,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
+import java.net.URLEncoder
import javax.inject.Inject
import javax.inject.Singleton
@@ -25,6 +29,8 @@ class Anime3rbExtractor @Inject constructor(
private val parser: Anime3rbHtmlParser,
private val videoStreamResolver: VideoStreamResolver,
private val autoFailoverPlaybackResolver: AutoFailoverPlaybackResolver,
+ private val sessionBridge: Anime3rbSessionBridge,
+ private val challengeSolver: VisibleCloudflareChallengeSolver,
private val dispatchers: DispatcherProvider,
) : AnimeExtractor {
@@ -49,13 +55,14 @@ class Anime3rbExtractor @Inject constructor(
}
override suspend fun search(query: String, page: Int, filters: CatalogFilters): PaginatedTitles {
- val url = BASE_URL.toHttpUrl().newBuilder()
- .addPathSegment("search")
- .addQueryParameter("q", query)
- .addQueryParameter("deep", "1")
- .addCatalogFilters(page, filters, includeDefaultSort = false)
- .build()
- return parser.parseSearch(fetchDocument(url.toString()), page)
+ val encodedQuery = URLEncoder.encode(query.trim(), Charsets.UTF_8.name())
+ val url = buildString {
+ append(BASE_URL)
+ append("/search?q=")
+ append(encodedQuery)
+ if (page > 1) append("&page=").append(page)
+ }
+ return parser.parseSearch(fetchDocument(url), page)
}
override suspend fun getAnimeDetails(slug: String): AnimeDetails {
@@ -105,7 +112,7 @@ class Anime3rbExtractor @Inject constructor(
?: mergedSources.firstOrNull { it.type == StreamType.MKV }?.url
return parsed.copy(
- playbackUrl = preferredPlayback ?: playbackAttempt.playbackUrl ?: parsed.playbackUrl,
+ playbackUrl = preferredPlayback ?: parsed.iframeUrl ?: playbackAttempt.playbackUrl ?: parsed.playbackUrl,
availableSources = mergedSources,
selectedServerId = playbackAttempt.selectedServerId ?: parsed.selectedServerId,
attemptedServerIds = playbackAttempt.attemptedServerIds,
@@ -114,9 +121,19 @@ class Anime3rbExtractor @Inject constructor(
private suspend fun fetchDocument(url: String): Document {
return withContext(dispatchers.io) {
+ sessionBridge.syncFromWebView()
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
- check(response.isSuccessful) { "Request failed: ${response.code} ${response.message}" }
+ if (response.code == 403 || CloudflareChallengeDetector.isChallenge(response)) {
+ challengeSolver.markChallengeRequired()
+ }
+ check(response.isSuccessful) {
+ if (response.code == 403) {
+ "Anime3rb Cloudflare session expired. Complete the verification screen, then retry."
+ } else {
+ "Request failed: ${response.code} ${response.message}"
+ }
+ }
Jsoup.parse(response.body?.string().orEmpty(), response.request.url.toString())
}
}
diff --git a/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParser.kt b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParser.kt
index 3cd5cd5..d02c887 100644
--- a/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParser.kt
+++ b/app/src/main/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParser.kt
@@ -228,9 +228,10 @@ class Anime3rbHtmlParser @Inject constructor() {
val directPlayerUrl = decodedSnapshot.livewireValue("video_url")
?: document.selectFirst("iframe")?.absUrl("src")
?: structuredVideos.firstNotNullOfOrNull { it.stringValue("embedUrl") }?.htmlDecoded()
+ val iframeUrl = document.selectFirst("iframe")?.absUrl("src")?.htmlDecoded()
+ ?: directPlayerUrl?.appendQueryParameterIfMissing("cinema", "760")
val activeSourceId = decodedSnapshot.livewireValue("video_source")
val csrfToken = document.selectFirst("meta[name=csrf-token]")?.attr("content")
- val iframeUrl = document.selectFirst("iframe")?.absUrl("src") ?: directPlayerUrl
val episodeTitle = document.selectFirst("h2.inline.text-lg.font-light")?.text()?.trim()
?: structuredVideos.firstNotNullOfOrNull { it.stringValue("description") }
?.substringAfter(" بعنوان ", missingDelimiterValue = "")
@@ -257,7 +258,7 @@ class Anime3rbHtmlParser @Inject constructor() {
VideoSource(
id = button.attr("data-video-source").ifBlank { "source-$index" },
label = label,
- url = directPlayerUrl.orEmpty(),
+ url = (iframeUrl ?: directPlayerUrl).orEmpty(),
type = StreamType.PLAYER_PAGE,
serverId = button.attr("data-video-source").ifBlank { null },
)
@@ -290,7 +291,7 @@ class Anime3rbHtmlParser @Inject constructor() {
episodeTitle = episodeTitle,
refererUrl = document.location(),
iframeUrl = iframeUrl,
- playbackUrl = directPlayerUrl,
+ playbackUrl = iframeUrl ?: directPlayerUrl,
availableSources = when {
availableSources.isNotEmpty() -> availableSources
structuredSources.isNotEmpty() -> structuredSources
@@ -535,6 +536,12 @@ class Anime3rbHtmlParser @Inject constructor() {
return if (this % 1.0 == 0.0) toInt().toString() else toString()
}
+ private fun String.appendQueryParameterIfMissing(name: String, value: String): String {
+ if (contains("$name=")) return this
+ val separator = if (contains("?")) "&" else "?"
+ return "$this$separator$name=$value"
+ }
+
private companion object {
val json = Json {
ignoreUnknownKeys = true
diff --git a/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt b/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt
index 3e444b0..e566214 100644
--- a/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt
+++ b/app/src/main/java/com/exapps/anistream/domain/model/AnimeModels.kt
@@ -146,6 +146,12 @@ data class TrailerItem(
val embedUrl: String,
)
+@Immutable
+data class TrailerRouteArgs(
+ val title: String,
+ val embedUrl: String,
+)
+
@Immutable
data class EpisodeStream(
val titleSlug: String,
diff --git a/app/src/main/java/com/exapps/anistream/presentation/details/DetailsScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/details/DetailsScreen.kt
index b0ed5a6..47ad9a0 100644
--- a/app/src/main/java/com/exapps/anistream/presentation/details/DetailsScreen.kt
+++ b/app/src/main/java/com/exapps/anistream/presentation/details/DetailsScreen.kt
@@ -53,6 +53,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.exapps.anistream.R
import com.exapps.anistream.domain.model.AnimeDetails
+import com.exapps.anistream.domain.model.TrailerItem
import com.exapps.anistream.domain.model.WatchStatus
import com.exapps.anistream.presentation.components.EmptyStateCard
import com.exapps.anistream.presentation.components.EpisodeRow
@@ -67,6 +68,7 @@ fun DetailsScreen(
onBack: () -> Unit,
onPlayEpisode: (String, Int) -> Unit,
onOpenDetails: (String) -> Unit,
+ onPlayTrailer: (TrailerItem) -> Unit,
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val uriHandler = LocalUriHandler.current
@@ -175,7 +177,7 @@ fun DetailsScreen(
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
details.trailers.forEach { trailer ->
AssistChip(
- onClick = { uriHandler.openUri(trailer.embedUrl) },
+ onClick = { onPlayTrailer(trailer) },
label = { Text(trailer.title) },
)
}
diff --git a/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt b/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt
index 1c74460..180512a 100644
--- a/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt
+++ b/app/src/main/java/com/exapps/anistream/presentation/navigation/AniStreamNavGraph.kt
@@ -20,6 +20,7 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
+import androidx.navigation.toRoute
import androidx.media3.common.util.UnstableApi
import com.exapps.anistream.R
import com.exapps.anistream.presentation.catalog.CatalogScreen
@@ -35,6 +36,7 @@ import com.exapps.anistream.presentation.player.PlayerScreen
import com.exapps.anistream.presentation.player.PlayerViewModel
import com.exapps.anistream.presentation.settings.SettingsScreen
import com.exapps.anistream.presentation.settings.SettingsViewModel
+import com.exapps.anistream.presentation.trailer.TrailerScreen
private enum class RootNavTarget {
HOME,
@@ -185,6 +187,9 @@ fun AniStreamNavGraph(
)
},
onOpenDetails = { slug -> navController.navigate(DetailsRoute(slug = slug)) },
+ onPlayTrailer = { trailer ->
+ navController.navigate(TrailerRoute(title = trailer.title, embedUrl = trailer.embedUrl))
+ },
)
}
@@ -200,6 +205,15 @@ fun AniStreamNavGraph(
},
)
}
+
+ composable
{ backStackEntry ->
+ val route = backStackEntry.toRoute()
+ TrailerScreen(
+ title = route.title,
+ embedUrl = route.embedUrl,
+ onBack = navController::popBackStack,
+ )
+ }
}
}
}
diff --git a/app/src/main/java/com/exapps/anistream/presentation/navigation/Routes.kt b/app/src/main/java/com/exapps/anistream/presentation/navigation/Routes.kt
index b97bedb..1ed1b8a 100644
--- a/app/src/main/java/com/exapps/anistream/presentation/navigation/Routes.kt
+++ b/app/src/main/java/com/exapps/anistream/presentation/navigation/Routes.kt
@@ -22,3 +22,9 @@ data class PlayerRoute(
val titleSlug: String,
val episodeNumber: Int,
)
+
+@Serializable
+data class TrailerRoute(
+ val title: String,
+ val embedUrl: String,
+)
diff --git a/app/src/main/java/com/exapps/anistream/presentation/player/PlayerScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/player/PlayerScreen.kt
index 4da9f16..3a25914 100644
--- a/app/src/main/java/com/exapps/anistream/presentation/player/PlayerScreen.kt
+++ b/app/src/main/java/com/exapps/anistream/presentation/player/PlayerScreen.kt
@@ -122,12 +122,7 @@ fun PlayerScreen(
?: stream.iframeUrl
?: stream.playbackUrl?.takeUnless { it.contains(".m3u8") || it.contains(".mp4") || it.contains(".mkv") }
var selectedUrl by rememberSaveable(stream.titleSlug, stream.episodeNumber, stream.playbackUrl, stream.selectedServerId) {
- mutableStateOf(
- playableSources.firstOrNull()?.url
- ?: stream.playbackUrl?.takeIf {
- it.contains(".m3u8") || it.contains(".mp4") || it.contains(".mkv")
- },
- )
+ mutableStateOf(null)
}
val context = LocalContext.current
diff --git a/app/src/main/java/com/exapps/anistream/presentation/trailer/TrailerScreen.kt b/app/src/main/java/com/exapps/anistream/presentation/trailer/TrailerScreen.kt
new file mode 100644
index 0000000..72cd50a
--- /dev/null
+++ b/app/src/main/java/com/exapps/anistream/presentation/trailer/TrailerScreen.kt
@@ -0,0 +1,64 @@
+package com.exapps.anistream.presentation.trailer
+
+import android.webkit.WebChromeClient
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.rounded.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.viewinterop.AndroidView
+import com.exapps.anistream.R
+
+@Composable
+@OptIn(ExperimentalMaterial3Api::class)
+fun TrailerScreen(
+ title: String,
+ embedUrl: String,
+ onBack: () -> Unit,
+) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(title.ifBlank { stringResource(id = R.string.details_trailers) }) },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(imageVector = Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(id = R.string.content_back))
+ }
+ },
+ )
+ },
+ ) { padding ->
+ AndroidView(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ factory = { context ->
+ WebView(context).apply {
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ settings.mediaPlaybackRequiresUserGesture = false
+ settings.cacheMode = WebSettings.LOAD_DEFAULT
+ webChromeClient = WebChromeClient()
+ webViewClient = WebViewClient()
+ loadUrl(embedUrl)
+ }
+ },
+ update = { webView ->
+ if (webView.url != embedUrl) {
+ webView.loadUrl(embedUrl)
+ }
+ },
+ )
+ }
+}
diff --git a/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt b/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt
index db1cf6b..6c6a7e6 100644
--- a/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt
+++ b/app/src/test/java/com/exapps/anistream/data/scraper/Anime3rbHtmlParserTest.kt
@@ -67,6 +67,7 @@ class Anime3rbHtmlParserTest {
assertNotNull(stream.livewireSnapshot)
assertEquals("66137468a5d62", stream.selectedServerId)
assertTrue(stream.playbackUrl.orEmpty().startsWith("https://video.vid3rb.com/player/"))
+ assertTrue(stream.playbackUrl.orEmpty().contains("cinema=760"))
assertFalse(stream.downloadLinks.isEmpty())
assertEquals(2, stream.nextEpisodeNumber)
}
@@ -168,7 +169,7 @@ class Anime3rbHtmlParserTest {
-
+
ريومين سوكونا