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
207 changes: 138 additions & 69 deletions app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -698,87 +698,95 @@ class StreamRepository @Inject constructor(
return true
}

/**
* Add a custom Stremio addon from URL -
* Fetches manifest and stores addon info
*/
suspend fun addCustomAddon(url: String, customName: String? = null): Result<Addon> = withContext(Dispatchers.IO) {
try {
val normalizedUrl = resolveAddonInstallUrl(url)
if (normalizedUrl.isBlank()) {
return@withContext Result.failure(IllegalArgumentException(context.getString(R.string.addon_error_url_empty)))
}
private suspend fun hydrateCustomAddon(url: String, customName: String? = null): Addon {
val normalizedUrl = resolveAddonInstallUrl(url)
if (normalizedUrl.isBlank()) {
throw IllegalArgumentException(context.getString(R.string.addon_error_url_empty))
}

httpLocalScraperRuntime.fetchInstallCandidate(
httpLocalScraperRuntime.fetchInstallCandidate(
url = normalizedUrl,
customName = customName
)?.let { candidate ->
val addonId = buildAddonInstanceId(candidate.manifest.id, normalizedUrl)
return Addon(
id = addonId,
name = candidate.name,
version = candidate.version,
description = candidate.description,
isInstalled = true,
isEnabled = true,
type = AddonType.CUSTOM,
url = normalizedUrl,
customName = customName
)?.let { httpCandidate ->
return@withContext Result.success(
installHttpLocalScraperCandidate(
normalizedUrl = normalizedUrl,
candidate = httpCandidate
)
)
}

val manifestUrl = getManifestUrl(normalizedUrl)

val manifest = try {
streamApi.getAddonManifest(manifestUrl)
} catch (manifestError: Exception) {
val httpCandidate = httpLocalScraperRuntime.fetchInstallCandidate(
url = normalizedUrl,
customName = customName
) ?: throw manifestError
return@withContext Result.success(
installHttpLocalScraperCandidate(
normalizedUrl = normalizedUrl,
candidate = httpCandidate
)
)
}
logo = candidate.logo,
manifest = candidate.manifest,
transportUrl = candidate.transportUrl
)
}

val transportUrl = getTransportUrl(normalizedUrl)
val addonManifest = convertToAddonManifest(manifest)
val resolvedName = customName?.trim()?.takeIf { it.isNotBlank() } ?: manifest.name
val addonId = buildAddonInstanceId(manifest.id, normalizedUrl)

// Classify the addon based on the resources its manifest declares.
// - If it declares `subtitles` but NOT `stream`, it's a pure subtitle addon
// (e.g. Wizdom, Ktuvit) and gets AddonType.SUBTITLE so the subtitle fetcher
// picks it up and the stream resolver correctly ignores it.
// - If it declares `stream` (with or without subtitles), keep it as CUSTOM so
// the stream resolver queries it. The subtitle fetcher has been updated
// separately to also include CUSTOM addons whose manifest declares a
// subtitles resource, so hybrid addons still get queried for both.
// - Everything else stays CUSTOM, matching the previous default.
// Fixes issue #80 where Wizdom/Ktuvit were installed but never queried because
// every user-added addon was hardcoded to CUSTOM regardless of its manifest.
val resourceNames = addonManifest.resources.map { it.name }.toSet()
val hasSubtitles = "subtitles" in resourceNames
val hasStream = "stream" in resourceNames
val addonType = when {
hasSubtitles && !hasStream -> AddonType.SUBTITLE
else -> AddonType.CUSTOM
}
val manifestUrl = getManifestUrl(normalizedUrl)

val newAddon = Addon(
val manifest = try {
streamApi.getAddonManifest(manifestUrl)
} catch (manifestError: Exception) {
val candidate = httpLocalScraperRuntime.fetchInstallCandidate(
url = normalizedUrl,
customName = customName
) ?: throw manifestError
val addonId = buildAddonInstanceId(candidate.manifest.id, normalizedUrl)
return Addon(
id = addonId,
name = resolvedName,
version = manifest.version,
description = manifest.description ?: "",
name = candidate.name,
version = candidate.version,
description = candidate.description,
isInstalled = true,
isEnabled = true,
type = addonType,
type = AddonType.CUSTOM,
url = normalizedUrl,
logo = manifest.logo,
manifest = addonManifest,
transportUrl = transportUrl
logo = candidate.logo,
manifest = candidate.manifest,
transportUrl = candidate.transportUrl
)
}

val transportUrl = getTransportUrl(normalizedUrl)
val addonManifest = convertToAddonManifest(manifest)
val resolvedName = customName?.trim()?.takeIf { it.isNotBlank() } ?: manifest.name
val addonId = buildAddonInstanceId(manifest.id, normalizedUrl)

val resourceNames = addonManifest.resources.map { it.name }.toSet()
val hasSubtitles = "subtitles" in resourceNames
val hasStream = "stream" in resourceNames
val addonType = when {
hasSubtitles && !hasStream -> AddonType.SUBTITLE
else -> AddonType.CUSTOM
}

return Addon(
id = addonId,
name = resolvedName,
version = manifest.version,
description = manifest.description ?: "",
isInstalled = true,
isEnabled = true,
type = addonType,
url = normalizedUrl,
logo = manifest.logo,
manifest = addonManifest,
transportUrl = transportUrl
)
}

/**
* Add a custom Stremio addon from URL -
* Fetches manifest and stores addon info
*/
suspend fun addCustomAddon(url: String, customName: String? = null): Result<Addon> = withContext(Dispatchers.IO) {
try {
val newAddon = hydrateCustomAddon(url, customName)
val addons = installedAddons.first().toMutableList()
// Remove existing addon with same ID if present
addons.removeAll { it.id == addonId }
addons.removeAll { it.id == newAddon.id }
addons.add(newAddon)
saveAddons(addons)

Expand All @@ -790,6 +798,62 @@ class StreamRepository @Inject constructor(
}
}

/**
* Refresh all installed custom addons by re-fetching manifests, invalidating stream caches,
* and returning a refresh report.
*/
suspend fun refreshInstalledAddons(): AddonRefreshReport = withContext(Dispatchers.IO) {
val currentAddons = installedAddons.first()
var refreshedCount = 0
var failedCount = 0

val updatedAddons = currentAddons.map { oldAddon ->
val isRefreshable = oldAddon.isInstalled &&
oldAddon.runtimeKind == RuntimeKind.STREMIO &&
defaultAddons.none { it.id == oldAddon.id }
val addonUrl = oldAddon.url?.takeIf { isRefreshable && it.isNotBlank() }
if (addonUrl == null) {
oldAddon
} else {
try {
val hydrated = hydrateCustomAddon(url = addonUrl, customName = oldAddon.name)
refreshedCount++
hydrated.copy(
id = oldAddon.id,
isEnabled = oldAddon.isEnabled,
isInstalled = oldAddon.isInstalled,
runtimeKind = oldAddon.runtimeKind,
installSource = oldAddon.installSource
)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
Log.w(TAG, "[AddonRefresh] Failed to refresh addon ${oldAddon.id} from $addonUrl", e)
failedCount++
oldAddon
}
}
}

saveAddons(updatedAddons)

synchronized(streamResultCache) { streamResultCache.clear() }
resolvedStreamCache.clear()
synchronized(streamAddonsCache) {
streamAddonsCache.clear()
cachedStreamAddonsFingerprint = null
}

runCatching {
val activeProfileId = profileManager.getProfileIdSync()
val bundleKey = streamResultCacheBundleKey(activeProfileId)
context.streamDataStore.edit { prefs ->
prefs.remove(bundleKey)
}
}

AddonRefreshReport(refreshed = refreshedCount, failed = failedCount)
}

private suspend fun installHttpLocalScraperCandidate(
normalizedUrl: String,
candidate: HttpLocalScraperInstallCandidate
Expand Down Expand Up @@ -4267,3 +4331,8 @@ data class ProgressiveStreamResult(
val totalAddons: Int,
val isFinal: Boolean
)

data class AddonRefreshReport(
val refreshed: Int = 0,
val failed: Int = 0
)
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ fun SettingsScreen(
}
"home_server" -> uiState.homeServerConnections.size + 3
"catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs
"stremio" -> stremioAddons.size // rows + add button
"stremio" -> stremioAddons.size + 1 // rows + refresh + add button
"plugins" -> pluginsMaxIndex
"accounts" -> 6 // Cloud + Trakt + Telegram + Force Sync + App Update + Privacy/Data + MDBList
else -> 0
Expand Down Expand Up @@ -1111,6 +1111,9 @@ fun SettingsScreen(
else -> viewModel.toggleAddon(addon.id)
}
}
contentFocusIndex == stremioAddons.size -> {
viewModel.refreshAddons()
}
else -> {
showCustomAddonInput = true
}
Expand Down Expand Up @@ -1623,13 +1626,15 @@ fun SettingsScreen(
)
"stremio" -> StremioAddonsSettings(
addons = stremioAddons,
isRefreshingAddons = uiState.isRefreshingAddons,
focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1,
focusedActionIndex = addonActionIndex,
onToggleAddon = { viewModel.toggleAddon(it) },
onMoveAddonUp = { viewModel.moveAddonUp(it) },
onMoveAddonDown = { viewModel.moveAddonDown(it) },
onDeleteAddon = { viewModel.removeAddon(it) },
onAddCustomAddon = { showCustomAddonInput = true }
onAddCustomAddon = { showCustomAddonInput = true },
onRefreshAddons = { viewModel.refreshAddons() }
)
"plugins" -> {
com.arflix.tv.ui.screens.plugin.PluginScreen(
Expand Down Expand Up @@ -4095,13 +4100,15 @@ private fun MobileSettingsSubPage(
"Addons" -> {
StremioAddonsSettings(
addons = stremioAddons,
isRefreshingAddons = uiState.isRefreshingAddons,
focusedIndex = -1,
focusedActionIndex = 0,
onToggleAddon = { viewModel.toggleAddon(it) },
onMoveAddonUp = { viewModel.moveAddonUp(it) },
onMoveAddonDown = { viewModel.moveAddonDown(it) },
onDeleteAddon = { viewModel.removeAddon(it) },
onAddCustomAddon = onAddCustomAddonClick
onAddCustomAddon = onAddCustomAddonClick,
onRefreshAddons = { viewModel.refreshAddons() }
)
}
"Plugins & Extensions" -> {
Expand Down Expand Up @@ -7430,20 +7437,47 @@ private fun CatalogActionChip(
@Composable
private fun StremioAddonsSettings(
addons: List<com.arflix.tv.data.model.Addon> = emptyList(),
isRefreshingAddons: Boolean = false,
focusedIndex: Int = -1,
focusedActionIndex: Int = 0,
onToggleAddon: (String) -> Unit = {},
onMoveAddonUp: (String) -> Unit = {},
onMoveAddonDown: (String) -> Unit = {},
onDeleteAddon: (String) -> Unit = {},
onAddCustomAddon: () -> Unit = {}
onAddCustomAddon: () -> Unit = {},
onRefreshAddons: () -> Unit = {}
) {
val isMobile = LocalDeviceType.current.isTouchDevice()

if (isMobile) {
Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
MobileSettingsCategory(title = stringResource(R.string.settings_section_add_addon)) {
MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_addon), subtitle = stringResource(R.string.settings_install_custom_addon), value = "", isFocused = false, showDivider = false, onClick = onAddCustomAddon)
MobileSettingsRow(
icon = Icons.Default.Refresh,
title = stringResource(R.string.refresh_addons),
subtitle = stringResource(R.string.settings_refresh_addons_desc),
value = if (isRefreshingAddons) {
stringResource(R.string.settings_pulling_latest_addon)
} else {
""
},
isFocused = false,
onClick = {
if (!isRefreshingAddons) onRefreshAddons()
}
)
MobileSettingsRow(
icon = Icons.Default.Add,
title = stringResource(R.string.add_addon),
subtitle = stringResource(R.string.settings_install_custom_addon),
value = "",
isFocused = false,
showDivider = false,
onClick = onAddCustomAddon
)
}
MobileSettingsCategory(title = stringResource(R.string.settings_section_my_addons)) {
if (addons.isEmpty()) {
Expand Down Expand Up @@ -7536,7 +7570,43 @@ private fun StremioAddonsSettings(
}
}
Spacer(modifier = Modifier.height(24.dp))
Row(modifier = Modifier.settingsFocusSlot(addons.size).fillMaxWidth().clickable(onClick = onAddCustomAddon).background(if (focusedIndex == addons.size) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == addons.size) 2.dp else 0.dp, color = if (focusedIndex == addons.size) Pink else Color.Transparent, shape = RoundedCornerShape(12.dp)).padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Row(
modifier = Modifier
.settingsFocusSlot(addons.size)
.fillMaxWidth()
.clickable(enabled = !isRefreshingAddons, onClick = onRefreshAddons)
.background(if (focusedIndex == addons.size) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp))
.border(width = if (focusedIndex == addons.size) 2.dp else 0.dp, color = if (focusedIndex == addons.size) Pink else Color.Transparent, shape = RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Refresh, contentDescription = null, tint = Pink, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(
if (isRefreshingAddons) {
R.string.settings_pulling_latest_addon
} else {
R.string.refresh_addons
}
),
style = ArflixTypography.button,
color = Pink
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier
.settingsFocusSlot(addons.size + 1)
.fillMaxWidth()
.clickable(onClick = onAddCustomAddon)
.background(if (focusedIndex == addons.size + 1) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp))
.border(width = if (focusedIndex == addons.size + 1) 2.dp else 0.dp, color = if (focusedIndex == addons.size + 1) Pink else Color.Transparent, shape = RoundedCornerShape(12.dp))
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(Icons.Default.Widgets, contentDescription = null, tint = Pink, modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(12.dp))
Text(stringResource(R.string.add_addon), style = ArflixTypography.button, color = Pink)
Expand Down
Loading
Loading