diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt index bf80487b..1ca3b8ef 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/StreamRepository.kt @@ -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 = 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 = 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) @@ -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 @@ -4267,3 +4331,8 @@ data class ProgressiveStreamResult( val totalAddons: Int, val isFinal: Boolean ) + +data class AddonRefreshReport( + val refreshed: Int = 0, + val failed: Int = 0 +) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 14548ebb..c25a49ff 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -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 @@ -1111,6 +1111,9 @@ fun SettingsScreen( else -> viewModel.toggleAddon(addon.id) } } + contentFocusIndex == stremioAddons.size -> { + viewModel.refreshAddons() + } else -> { showCustomAddonInput = true } @@ -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( @@ -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" -> { @@ -7430,20 +7437,47 @@ private fun CatalogActionChip( @Composable private fun StremioAddonsSettings( addons: List = 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()) { @@ -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) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 95b8afac..cbac656b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -179,6 +179,7 @@ data class SettingsUiState( val packError: String? = null, // Addons val addons: List = emptyList(), + val isRefreshingAddons: Boolean = false, val torrServerBaseUrl: String = "", val homeServerConnection: HomeServerConnection? = null, val homeServerConnections: List = emptyList(), @@ -1691,6 +1692,49 @@ class SettingsViewModel @Inject constructor( } } + fun refreshAddons() { + if (_uiState.value.isRefreshingAddons) return + _uiState.value = _uiState.value.copy(isRefreshingAddons = true) + viewModelScope.launch { + try { + if (authRepository.hasValidCloudSyncSession()) { + val restoreResult = restoreCloudStateToLocalInternal( + silent = true, + pushPendingLocalFirst = false + ) + if (restoreResult == CloudRestoreResult.FAILED) { + _uiState.value = _uiState.value.copy( + isRefreshingAddons = false, + toastMessage = "Cloud restore failed; addons were not changed", + toastType = ToastType.ERROR + ) + return@launch + } + } + val report = streamRepository.refreshInstalledAddons() + val updatedAddons = streamRepository.installedAddons.first() + runCatching { + catalogRepository.syncAddonCatalogs(updatedAddons) + } + val toast = "${report.refreshed} addons refreshed, ${report.failed} failed" + _uiState.value = _uiState.value.copy( + addons = updatedAddons, + isRefreshingAddons = false, + toastMessage = toast, + toastType = if (report.failed == 0) ToastType.SUCCESS else ToastType.INFO + ) + syncLocalStateToCloud(silent = true) + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + _uiState.value = _uiState.value.copy( + isRefreshingAddons = false, + toastMessage = "Failed to refresh addons", + toastType = ToastType.ERROR + ) + } + } + } + private fun observeAuthState() { viewModelScope.launch { authRepository.authState.collect { state -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fe7f3d0a..4e623603 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -597,6 +597,9 @@ From %1$s From Home Server Custom catalog + Refresh Addons + Pull latest addon changes for current profile + Pulling latest addons ADD ADDON Install a custom Stremio addon by URL MY ADDONS