Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions app/src/main/kotlin/com/arflix/tv/core/plugin/PluginSafety.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,13 @@ internal object PluginSafety {
}
}

// No path traversal allowed
if (filename.contains("/") || filename.contains("\\") || filename.contains("..")) {
// No path traversal or absolute paths allowed
val file = java.io.File(filename)
if (file.isAbsolute) {
return false
}
val normalized = file.normalize().path.replace("\\", "/")
if (normalized.startsWith("../") || normalized == "..") {
return false
}

Expand Down
58 changes: 49 additions & 9 deletions app/src/main/kotlin/com/arflix/tv/ui/components/StreamSelector.kt
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,14 @@ fun StreamSelector(
addonOrderedIds: List<String> = emptyList(),
completedAddons: Int = 0,
totalAddons: Int = 0,
streamSearchStartTime: Long = 0L,
pluginScrapersLoading: Boolean = false,
loadingPluginNames: Set<String> = emptySet(),
onFocusedStream: (StreamSource) -> Unit = {},
onSelect: (StreamSource) -> Unit = {},
onClose: () -> Unit = {}
) {
val isRtlLayoutDirection = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
var focusedIndex by remember { mutableIntStateOf(0) }
var focusedTabIndex by remember { mutableIntStateOf(0) }
var selectedTabIndex by remember { mutableIntStateOf(0) }
Expand All @@ -130,6 +134,18 @@ fun StreamSelector(
val listState = rememberTvLazyListState()
val focusRequester = remember { FocusRequester() }
val isMobile = LocalDeviceType.current.isTouchDevice()
val pluginPrefix = stringResource(R.string.plugin_prefix)

var elapsedSeconds by remember { mutableIntStateOf(0) }
LaunchedEffect(streamSearchStartTime) {
if (streamSearchStartTime > 0L) {
elapsedSeconds = 0
while (true) {
kotlinx.coroutines.delay(1000L)
elapsedSeconds = ((System.currentTimeMillis() - streamSearchStartTime) / 1000).toInt()
}
}
}

// Request focus when visible
LaunchedEffect(isVisible) {
Expand Down Expand Up @@ -302,7 +318,17 @@ fun StreamSelector(
.background(Color.Black.copy(alpha = 0.95f))
.onKeyEvent { event ->
if (event.type == KeyEventType.KeyDown) {
when (event.key) {
val isRtl = isRtlLayoutDirection
val actualKey = event.key
val logicalKey = if (isRtl) {
when (actualKey) {
Key.DirectionLeft -> Key.DirectionRight
Key.DirectionRight -> Key.DirectionLeft
else -> actualKey
}
} else actualKey

when (logicalKey) {
Key.Back, Key.Escape -> {
onClose()
true
Expand Down Expand Up @@ -413,6 +439,8 @@ fun StreamSelector(
hasStreamingAddons = hasStreamingAddons,
completedAddons = completedAddons,
totalAddons = totalAddons,
pluginScrapersLoading = pluginScrapersLoading,
loadingPluginNames = loadingPluginNames,
onFilterSelected = { index ->
selectedFilterIndex = index
focusedFilterIndex = index
Expand Down Expand Up @@ -519,7 +547,7 @@ fun StreamSelector(

// Stream list or loading/empty states
if (streams.isEmpty()) {
val stillSearching = isLoading || (completedAddons < totalAddons && totalAddons > 0)
val stillSearching = isLoading || (completedAddons < totalAddons && totalAddons > 0) || pluginScrapersLoading
Box(
modifier = Modifier
.fillMaxSize()
Expand All @@ -537,7 +565,12 @@ fun StreamSelector(
LoadingIndicator(color = Pink, size = 40.dp)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = if (totalAddons > 0) "Searching addons ($completedAddons/$totalAddons)..." else stringResource(R.string.finding_sources),
text = buildString {
if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", ")))
else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "..."))
else if (totalAddons > 0) append("Searching addons ($completedAddons/$totalAddons)...")
else append(stringResource(R.string.finding_sources))
},
style = ArflixTypography.body.copy(
fontSize = 14.sp,
fontWeight = FontWeight.Medium
Expand Down Expand Up @@ -631,6 +664,8 @@ private fun OledSourceSelectorTv(
hasStreamingAddons: Boolean,
completedAddons: Int,
totalAddons: Int,
pluginScrapersLoading: Boolean,
loadingPluginNames: Set<String>,
onFilterSelected: (Int) -> Unit,
onAddonSelected: (Int) -> Unit,
onSelect: (StreamSource) -> Unit
Expand Down Expand Up @@ -736,7 +771,9 @@ private fun OledSourceSelectorTv(
isLoading = isLoading,
completedAddons = completedAddons,
totalAddons = totalAddons,
hasStreamingAddons = hasStreamingAddons
hasStreamingAddons = hasStreamingAddons,
pluginScrapersLoading = pluginScrapersLoading,
loadingPluginNames = loadingPluginNames
)
flatPresentations.isEmpty() -> SourceEmptyState(
isLoading = false,
Expand Down Expand Up @@ -1520,6 +1557,8 @@ private fun SourceEmptyState(
completedAddons: Int,
totalAddons: Int,
hasStreamingAddons: Boolean,
pluginScrapersLoading: Boolean = false,
loadingPluginNames: Set<String> = emptySet(),
message: String? = null
) {
Box(
Expand All @@ -1533,15 +1572,16 @@ private fun SourceEmptyState(
.border(1.dp, OledMutedBorder, RoundedCornerShape(18.dp))
.padding(horizontal = 42.dp, vertical = 34.dp)
) {
val stillSearching = isLoading || (completedAddons < totalAddons && totalAddons > 0)
val stillSearching = isLoading || (completedAddons < totalAddons && totalAddons > 0) || pluginScrapersLoading
if (stillSearching) {
LoadingIndicator(color = Color.White, size = 42.dp)
Spacer(modifier = Modifier.height(14.dp))
Text(
text = if (totalAddons > 0) {
"Searching addons ($completedAddons/$totalAddons)"
} else {
stringResource(R.string.finding_sources)
text = buildString {
if (loadingPluginNames.isNotEmpty()) append(stringResource(R.string.plugins_loading, loadingPluginNames.joinToString(", ")))
else if (pluginScrapersLoading) append(stringResource(R.string.plugins_loading, "..."))
else if (totalAddons > 0) append("Searching addons ($completedAddons/$totalAddons)...")
else append(stringResource(R.string.finding_sources))
},
style = ArflixTypography.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium),
color = TextSecondary
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,9 @@ fun DetailsScreen(
addonOrderedIds = uiState.addonOrderedIds,
completedAddons = uiState.completedAddons,
totalAddons = uiState.totalAddons,
streamSearchStartTime = uiState.streamSearchStartTime,
pluginScrapersLoading = uiState.pluginScrapersLoading,
loadingPluginNames = uiState.loadingPluginNames,
onFocusedStream = { stream ->
viewModel.prewarmStreamsAround(stream, uiState.streams)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.Locale
import com.arflix.tv.core.plugin.PluginManager
import com.arflix.tv.domain.model.LocalScraperResult
import javax.inject.Inject

data class DetailsUiState(
Expand All @@ -62,6 +64,9 @@ data class DetailsUiState(
val streams: List<StreamSource> = emptyList(),
val subtitles: List<Subtitle> = emptyList(),
val isLoadingStreams: Boolean = false,
val streamSearchStartTime: Long = 0L,
val pluginScrapersLoading: Boolean = false,
val loadingPluginNames: Set<String> = emptySet(),
val completedAddons: Int = 0,
val totalAddons: Int = 0,
val hasStreamingAddons: Boolean = true,
Expand Down Expand Up @@ -171,6 +176,7 @@ private fun isSupplementalStream(stream: StreamSource): Boolean =
class DetailsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val mediaRepository: MediaRepository,
private val pluginManager: PluginManager,
private val profileManager: ProfileManager,
private val traktRepository: TraktRepository,
private val streamRepository: StreamRepository,
Expand Down Expand Up @@ -1332,7 +1338,9 @@ class DetailsViewModel @Inject constructor(
completedAddons = 0,
totalAddons = 0,
streams = emptyList(),
subtitles = emptyList()
subtitles = emptyList(),
streamSearchStartTime = System.currentTimeMillis(),
pluginScrapersLoading = false
)
val requestId = ++loadStreamsRequestId
val requestMediaType = currentMediaType
Expand Down Expand Up @@ -1377,7 +1385,9 @@ class DetailsViewModel @Inject constructor(
totalAddons = 0,
streams = emptyList(),
subtitles = emptyList(),
addonOrderedIds = orderedAddonIds
addonOrderedIds = orderedAddonIds,
streamSearchStartTime = System.currentTimeMillis(),
pluginScrapersLoading = false
)

if (requestMediaType == MediaType.MOVIE) {
Expand Down Expand Up @@ -1422,6 +1432,56 @@ class DetailsViewModel @Inject constructor(
)
}

var pluginScraperJob: kotlinx.coroutines.Job? = null
pluginScraperJob = viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(pluginScrapersLoading = true)
val tmdbIdStr = requestMediaId.toString()
val pluginMediaType = if (requestMediaType == MediaType.MOVIE) "movie" else "tv"
pluginManager.executeScrapersStreaming(
tmdbId = tmdbIdStr,
mediaType = pluginMediaType,
season = if (requestMediaType != MediaType.MOVIE) (season ?: 1) else null,
episode = if (requestMediaType != MediaType.MOVIE) (episode ?: 1) else null
).collect { pair ->
val scraperInfo = pair.first
val results: List<LocalScraperResult>? = pair.second
if (!isCurrentRequest()) return@collect

val current = _uiState.value
if (results == null) {
// Plugin started loading
_uiState.value = current.copy(
loadingPluginNames = current.loadingPluginNames + scraperInfo.name
)
} else {
// Plugin finished loading (with or without results)
val updatedNames = current.loadingPluginNames - scraperInfo.name
if (results.isNotEmpty()) {
val pluginStreams = results.map { it.toStreamSource() }
val merged = sortPlayableStreamsFirst(
(current.streams + pluginStreams)
.distinctBy { "${it.url?.trim().orEmpty()}|${it.source}" }
)
_uiState.value = current.copy(
streams = merged,
isLoadingStreams = false,
loadingPluginNames = updatedNames
)
} else {
_uiState.value = current.copy(
loadingPluginNames = updatedNames
)
}
}
}
} catch (e: Exception) {
Log.w(TAG, "[PluginScrapers] streaming execution failed: ${e.message}")
} finally {
_uiState.value = _uiState.value.copy(pluginScrapersLoading = false, loadingPluginNames = emptySet())
}
}

val result = if (currentMediaType == MediaType.MOVIE) {
val enabledAddons = streamRepository.installedAddons.first()
.filter { it.isEnabled && it.type != com.arflix.tv.data.model.AddonType.SUBTITLE }
Expand Down Expand Up @@ -1480,7 +1540,7 @@ class DetailsViewModel @Inject constructor(
homeServerAppendJob?.isActive == true || vodAppendJob?.isActive == true
_uiState.value = _uiState.value.copy(
isLoadingStreams = mergedStreams.isEmpty() &&
(!progressive.isFinal || hasHomeServerConnections || supplementalSourcesStillLoading),
(!progressive.isFinal || hasHomeServerConnections || supplementalSourcesStillLoading || pluginScraperJob?.isActive == true),
completedAddons = progressive.completedAddons,
totalAddons = progressive.totalAddons,
streams = mergedStreams,
Expand Down Expand Up @@ -1536,7 +1596,7 @@ class DetailsViewModel @Inject constructor(
homeServerAppendJob?.isActive == true || vodAppendJob?.isActive == true
_uiState.value = _uiState.value.copy(
isLoadingStreams = mergedStreams.isEmpty() &&
(!progressive.isFinal || hasHomeServerConnections || supplementalSourcesStillLoading),
(!progressive.isFinal || hasHomeServerConnections || supplementalSourcesStillLoading || pluginScraperJob?.isActive == true),
completedAddons = progressive.completedAddons,
totalAddons = progressive.totalAddons,
streams = mergedStreams,
Expand Down Expand Up @@ -2510,4 +2570,26 @@ private object DetailsVMRegexes {
pattern = "\\b[a-z0-9-]+\\.(?:com|net|org|xyz|top|click|link|site|online|shop|info)\\b",
option = RegexOption.IGNORE_CASE
)

}

private fun LocalScraperResult.toStreamSource(): StreamSource = StreamSource(
source = title,
addonName = provider ?: name ?: "Plugin",
addonId = "plugin_${provider?.lowercase()?.replace(" ", "_") ?: "unknown"}",
quality = quality ?: "Unknown",
size = size ?: "",
sizeBytes = null,
url = url,
infoHash = infoHash,
fileIdx = null,
behaviorHints = headers?.let { hdrs ->
com.arflix.tv.data.model.StreamBehaviorHints(
notWebReady = false,
proxyHeaders = com.arflix.tv.data.model.ProxyHeaders(request = hdrs)
)
},
subtitles = emptyList(),
sources = emptyList(),
description = null
)
2 changes: 2 additions & 0 deletions app/src/main/res/values-iw/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,6 @@
<string name="ai_key_saved_title">מפתח API נשמר</string>
<string name="ai_key_saved_subtitle">תרגום AI מוכן לשימוש</string>
<string name="ai_qr_scan_hint">סרוק עם הטלפון — בחר Groq או Gemini</string>
<string name="plugin_prefix">Plugin: </string>
<string name="plugins_loading">פלאגינים: %1$s</string>
</resources>
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@
<string name="plugin_risky_enable_message">This plugin may be risky.</string>
<string name="plugin_risky_enable_cancel">Cancel</string>
<string name="plugin_risky_enable_confirm">Enable</string>
<string name="plugin_prefix">Plugin: </string>
<string name="plugins_loading">Plugins: %1$s</string>

<!-- IPTV / Live TV Quick Zap Navigation -->
<string name="select_category">Select Category</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ class PluginManager @Inject constructor() {
mediaType: String,
season: Int? = null,
episode: Int? = null
): Flow<Pair<ScraperInfo, List<LocalScraperResult>>> = emptyFlow()
): Flow<Pair<ScraperInfo, List<LocalScraperResult>?>> = emptyFlow()

suspend fun executeScraper(
scraper: ScraperInfo,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@ class PluginManager @Inject constructor(
mediaType: String,
season: Int? = null,
episode: Int? = null
): Flow<Pair<ScraperInfo, List<LocalScraperResult>>> = channelFlow {
): Flow<Pair<ScraperInfo, List<LocalScraperResult>?>> = channelFlow {
val enabledList = enabledScrapers.first()
.filter { it.supportsType(mediaType) }

Expand All @@ -705,10 +705,11 @@ class PluginManager @Inject constructor(
enabledList.forEach { scraper ->
launch {
try {
send(scraper to null)
val results = executeScraperWithSingleFlight(scraper, tmdbId, mediaType, season, episode)
send(scraper to results)
} catch (e: Exception) {
Log.e(TAG, "Scraper ${scraper.name} failed in streaming: ${e.message}")
Log.w(TAG, "Scraper ${scraper.id} streaming failed: ${e.message}")
send(scraper to emptyList())
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,13 @@ class ExternalRepoParser @Inject constructor(
private suspend fun fetchPluginList(url: String): List<ExternalPluginEntry>? = withContext(Dispatchers.IO) {
val body = fetchBody(url) ?: return@withContext null
try {
pluginListAdapter.fromJson(body.trim())
val list = pluginListAdapter.fromJson(body.trim()) ?: return@withContext null
list.map { entry ->
entry.copy(
url = resolveUrl(url, entry.url),
iconUrl = entry.iconUrl?.let { resolveUrl(url, it) }
)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to parse plugin list from $url: ${e.message}")
null
Expand Down
Loading