diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4f2fb864c..7fcd76b9f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -67,6 +67,16 @@ + + + + + + + + + + diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index 16e4b3f09..d21822ded 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -166,6 +166,7 @@ class MainActivity : ComponentActivity() { private var jankStats: JankStats? = null private var pendingLauncherRequest by mutableStateOf(null) + private var pendingInstallPackUrl by mutableStateOf(null) // StartupViewModel for parallel loading during splash private val startupViewModel: StartupViewModel by viewModels() @@ -204,6 +205,7 @@ class MainActivity : ComponentActivity() { @Suppress("DEPRECATION") overridePendingTransition(0, 0) pendingLauncherRequest = parseLauncherRequest(intent) + pendingInstallPackUrl = parseInstallPackUrl(intent) val crashPrefs = getSharedPreferences("arvio_crash_store", Context.MODE_PRIVATE) if (crashPrefs.getBoolean("has_pending_crash_report", false)) { @@ -356,6 +358,8 @@ class MainActivity : ComponentActivity() { skipProfileSelection = skipProfileSelection, pendingLauncherRequest = pendingLauncherRequest, onConsumeLauncherRequest = { pendingLauncherRequest = null }, + pendingInstallPackUrl = pendingInstallPackUrl, + onConsumeInstallPackUrl = { pendingInstallPackUrl = null }, preloadedCategories = startupState.categories, preloadedHeroItem = startupState.heroItem, preloadedHeroLogoUrl = startupState.heroLogoUrl, @@ -394,6 +398,7 @@ class MainActivity : ComponentActivity() { super.onNewIntent(intent) setIntent(intent) pendingLauncherRequest = parseLauncherRequest(intent) + pendingInstallPackUrl = parseInstallPackUrl(intent) } override fun onWindowFocusChanged(hasFocus: Boolean) { @@ -421,6 +426,19 @@ private fun MainActivity.parseLauncherRequest(intent: android.content.Intent?): return intent?.data?.toLauncherContinueWatchingRequest() } +private fun MainActivity.parseInstallPackUrl(intent: android.content.Intent?): String? { + val data = intent?.data ?: return null + val scheme = data.scheme ?: return null + val host = data.host ?: return null + return if (scheme == "arvio" && host == "install-pack") { + data.getQueryParameter("url") + } else if ((scheme == "http" || scheme == "https") && host == "arvio.app" && data.path?.startsWith("/install-pack") == true) { + data.getQueryParameter("url") + } else { + null + } +} + private fun ComponentActivity.runAfterFirstDraw(block: () -> Unit) { val content = window.decorView content.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { @@ -541,6 +559,8 @@ fun ArflixApp( skipProfileSelection: Boolean? = null, pendingLauncherRequest: LauncherContinueWatchingRequest? = null, onConsumeLauncherRequest: () -> Unit = {}, + pendingInstallPackUrl: String? = null, + onConsumeInstallPackUrl: () -> Unit = {}, preloadedCategories: List = emptyList(), preloadedHeroItem: com.arflix.tv.data.model.MediaItem? = null, preloadedHeroLogoUrl: String? = null, @@ -690,6 +710,19 @@ fun ArflixApp( } onConsumeLauncherRequest() } + + LaunchedEffect(activeProfile?.id, pendingInstallPackUrl) { + val packUrl = pendingInstallPackUrl ?: return@LaunchedEffect + if (activeProfile == null) return@LaunchedEffect + + val encodedUrl = java.net.URLEncoder.encode(packUrl, "UTF-8") + val route = "settings?initialSection=catalogs&installPackUrl=$encodedUrl" + navController.navigate(route) { + popUpTo(Screen.ProfileSelection.route) { inclusive = true } + launchSingleTop = true + } + onConsumeInstallPackUrl() + } } private fun enqueueFullTraktSync(context: android.content.Context) { diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt b/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt index 1b3c71220..e958466e9 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/CatalogModels.kt @@ -100,9 +100,38 @@ data class CatalogConfig( val collectionTileShape: CollectionTileShape = CollectionTileShape.LANDSCAPE, val collectionHideTitle: Boolean = false, val collectionSources: List = emptyList(), - val requiredAddonUrls: List = emptyList() + val requiredAddonUrls: List = emptyList(), + val packId: String? = null, + val packName: String? = null ) : Serializable +val CatalogConfig.effectivePackId: String + get() = packId ?: when (sourceType) { + CatalogSourceType.PREINSTALLED -> "system" + CatalogSourceType.ADDON -> "addon" + CatalogSourceType.TRAKT -> "trakt" + CatalogSourceType.MDBLIST -> "mdblist" + CatalogSourceType.HOME_SERVER -> "home_server" + } + +val CatalogConfig.effectivePackName: String + get() = packName ?: when (sourceType) { + CatalogSourceType.PREINSTALLED -> "System Catalogs" + CatalogSourceType.ADDON -> "Addon Catalogs" + CatalogSourceType.TRAKT -> "Trakt Catalogs" + CatalogSourceType.MDBLIST -> "MDBlist Catalogs" + CatalogSourceType.HOME_SERVER -> "Home Server Catalogs" + } + +val CatalogConfig.isBulkDeletablePack: Boolean + get() = packId != null && + packId != "system" && + packId != "addon" && + packId != "trakt" && + packId != "mdblist" && + packId != "home_server" && + packId != "individual" + data class CatalogDiscoveryResult( val id: String, val title: String, @@ -123,3 +152,17 @@ data class CatalogValidationResult( val sourceType: CatalogSourceType? = null, val error: String? = null ) + +data class CatalogPackManifest( + val id: String?, + val name: String?, + val author: String? = null, + val version: String? = null, + val description: String? = null, + val catalogs: List? +) : Serializable + +data class CatalogPackItem( + val name: String?, + val url: String? +) : Serializable diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt index 83fa1a094..aee904ac6 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CatalogRepository.kt @@ -10,6 +10,8 @@ import com.arflix.tv.data.model.AddonCatalog import com.arflix.tv.data.model.AddonType import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogKind +import com.arflix.tv.data.model.CatalogPackManifest +import com.arflix.tv.data.model.CatalogPackItem import com.arflix.tv.data.model.CollectionGroupKind import com.arflix.tv.data.model.CollectionSourceConfig import com.arflix.tv.data.model.CollectionTileShape @@ -706,6 +708,112 @@ class CatalogRepository @Inject constructor( return true } + suspend fun fetchCatalogPackManifest(packUrl: String): Result { + val trimmed = packUrl.trim() + if (!trimmed.startsWith("http://", ignoreCase = true) && !trimmed.startsWith("https://", ignoreCase = true)) { + return Result.failure(IllegalArgumentException("Invalid URL: must start with http:// or https://")) + } + + val json = fetchUrl(trimmed) + ?: return Result.failure(IllegalArgumentException("Failed to fetch catalog pack from URL")) + val manifest = try { + val type = object : com.google.gson.reflect.TypeToken() {}.type + gson.fromJson(json, type) + } catch (e: Exception) { + null + } ?: return Result.failure(IllegalArgumentException("Failed to parse catalog pack manifest")) + + if (manifest.id.isNullOrBlank() || manifest.name.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: missing ID or Name")) + } + val cats = manifest.catalogs + if (cats.isNullOrEmpty()) { + return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: catalogs list is empty or missing")) + } + val normalizedUrls = mutableSetOf() + for (item in cats) { + if (item.name.isNullOrBlank() || item.url.isNullOrBlank()) { + return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: catalog items must have a name and url")) + } + val normalized = CatalogUrlParser.normalize(item.url).lowercase() + if (!normalizedUrls.add(normalized)) { + return Result.failure(IllegalArgumentException("Invalid catalog pack manifest: duplicate catalog URL '$normalized'")) + } + } + return Result.success(manifest) + } + + suspend fun addCatalogPack(packUrl: String, manifest: CatalogPackManifest? = null): Result { + val finalManifest = if (manifest != null) { + manifest + } else { + val manifestResult = fetchCatalogPackManifest(packUrl) + if (manifestResult.isFailure) return manifestResult + manifestResult.getOrThrow() + } + + val current = getCatalogs().toMutableList() + val addedConfigs = mutableListOf() + + val manifestId = finalManifest.id ?: return Result.failure(IllegalArgumentException("Manifest missing ID")) + val manifestName = finalManifest.name ?: return Result.failure(IllegalArgumentException("Manifest missing Name")) + val manifestCatalogs = finalManifest.catalogs ?: return Result.failure(IllegalArgumentException("Manifest missing catalogs")) + + val normalizedPackUrl = CatalogUrlParser.normalize(packUrl).lowercase() + val derivedPackId = "pack_${sha256Short(normalizedPackUrl + "|" + manifestId)}" + + for (item in manifestCatalogs) { + val itemUrl = item.url ?: continue + val validation = validateCatalogUrl(itemUrl) + if (!validation.isValid || validation.normalizedUrl == null || validation.sourceType == null) { + continue + } + val normalizedUrl = validation.normalizedUrl + val sourceType = validation.sourceType + + // Skip if this URL is already added + if (current.any { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) { + continue + } + + val resolved = resolveMetadata(normalizedUrl, sourceType) + ?: fallbackMetadata(normalizedUrl, sourceType) + ?: continue + + val stableId = "custom_${sha256Short(derivedPackId + "|" + normalizedUrl)}" + val newCatalog = CatalogConfig( + id = stableId, + title = item.name?.trim()?.ifBlank { resolved.title } ?: resolved.title, + sourceType = sourceType, + sourceUrl = normalizedUrl, + sourceRef = resolved.sourceRef, + isPreinstalled = false, + packId = derivedPackId, + packName = manifestName + ) + addedConfigs.add(newCatalog) + } + + if (addedConfigs.isEmpty()) { + return Result.failure(IllegalArgumentException("No new or valid catalogs found in this pack to import")) + } + + current.addAll(0, addedConfigs) + saveCatalogs(current) + return Result.success(finalManifest) + } + + suspend fun removeCatalogPack(packId: String): Result { + val current = getCatalogs().toMutableList() + val beforeSize = current.size + current.removeAll { it.packId == packId } + if (current.size == beforeSize) { + return Result.failure(IllegalArgumentException("No catalogs found for pack: $packId")) + } + saveCatalogs(current) + return Result.success(Unit) + } + suspend fun addCustomCatalog(rawUrl: String): Result { val validation = validateCatalogUrl(rawUrl) if (!validation.isValid || validation.normalizedUrl == null || validation.sourceType == null) { @@ -1019,11 +1127,11 @@ class CatalogRepository @Inject constructor( private suspend fun fetchUrl(url: String): String? { return withContext(Dispatchers.IO) { - val request = Request.Builder() - .url(url) - .header("User-Agent", OkHttpProvider.userAgentOr("Mozilla/5.0 (Android TV; ARVIO)")) - .build() try { + val request = Request.Builder() + .url(url) + .header("User-Agent", OkHttpProvider.userAgentOr("Mozilla/5.0 (Android TV; ARVIO)")) + .build() okHttpClient.newCall(request).execute().use { response -> if (!response.isSuccessful) return@use null response.body?.string() diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index e45972a73..29c293014 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -290,7 +290,7 @@ fun AppNavigation( // Settings screen composable( - route = "settings?autoCloudAuth={autoCloudAuth}&initialSection={initialSection}", + route = "settings?autoCloudAuth={autoCloudAuth}&initialSection={initialSection}&installPackUrl={installPackUrl}", arguments = listOf( navArgument("autoCloudAuth") { type = NavType.BoolType @@ -300,15 +300,22 @@ fun AppNavigation( type = NavType.StringType nullable = true defaultValue = null + }, + navArgument("installPackUrl") { + type = NavType.StringType + nullable = true + defaultValue = null } ) ) { backStackEntry -> val autoCloudAuth = backStackEntry.arguments?.getBoolean("autoCloudAuth") ?: false val initialSection = backStackEntry.arguments?.getString("initialSection") + val installPackUrl = backStackEntry.arguments?.getString("installPackUrl") SettingsScreen( currentProfile = currentProfile, autoStartCloudAuth = autoCloudAuth, initialSection = initialSection, + installPackUrl = installPackUrl, onNavigateToHome = { navigateHome() }, onNavigateToSearch = { navigateTopLevel(Screen.Search.route) }, onNavigateToTv = { navigateTopLevel(Screen.Tv.createRoute()) }, 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 20e2642e3..626b599f8 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 @@ -88,6 +88,8 @@ import androidx.compose.material.icons.filled.SystemUpdate import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.VpnKey import androidx.compose.material.icons.filled.QrCode +import androidx.compose.material.icons.filled.Unarchive +import androidx.compose.material.icons.filled.Archive import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.asImageBitmap import androidx.tv.material3.ClickableSurfaceDefaults @@ -153,6 +155,10 @@ import androidx.tv.material3.Text import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogDiscoveryResult import com.arflix.tv.data.model.CatalogKind +import com.arflix.tv.data.model.CatalogPackManifest +import com.arflix.tv.data.model.effectivePackId +import com.arflix.tv.data.model.effectivePackName +import com.arflix.tv.data.model.isBulkDeletablePack import com.arflix.tv.data.model.CatalogSourceType import com.arflix.tv.data.model.QualityFilterConfig import com.arflix.tv.data.model.RuntimeKind @@ -293,6 +299,7 @@ fun SettingsScreen( currentProfile: com.arflix.tv.data.model.Profile? = null, autoStartCloudAuth: Boolean = false, initialSection: String? = null, + installPackUrl: String? = null, onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToTv: () -> Unit = {}, @@ -369,6 +376,19 @@ fun SettingsScreen( var renameCatalogId by remember { mutableStateOf("") } var renameCatalogTitle by remember { mutableStateOf("") } + // Catalog Pack states + var showCatalogPackInput by remember { mutableStateOf(false) } + var catalogPackInputUrl by remember { mutableStateOf("") } + var showDeletePackConfirm by remember { mutableStateOf(false) } + var deletePackId by remember { mutableStateOf("") } + var deletePackName by remember { mutableStateOf("") } + + LaunchedEffect(installPackUrl) { + if (!installPackUrl.isNullOrBlank()) { + viewModel.loadPackManifest(installPackUrl) + } + } + // Input modal states var showCustomAddonInput by remember { mutableStateOf(false) } var customAddonUrl by remember { mutableStateOf("") } @@ -427,7 +447,7 @@ fun SettingsScreen( 2 + uiState.iptvPlaylists.size // Add + rows + refresh + clear } "home_server" -> uiState.homeServerConnections.size + 3 - "catalogs" -> uiState.catalogs.size // Add + rows + "catalogs" -> uiState.catalogs.size + 1 // Add + Import + catalogs "stremio" -> stremioAddons.size // rows + add button "accounts" -> 5 // Cloud + Trakt + Telegram + Force Sync + App Update + Privacy/Data else -> 0 @@ -676,7 +696,12 @@ fun SettingsScreen( uiState.showAppUpdateDialog || uiState.showUnknownSourcesDialog || showCloudDisconnectConfirm || - showTraktDisconnectConfirm + showTraktDisconnectConfirm || + showCatalogPackInput || + showDeletePackConfirm || + uiState.isPackLoading || + uiState.packError != null || + uiState.pendingPackManifest != null Box( modifier = Modifier @@ -746,7 +771,7 @@ fun SettingsScreen( ) ) { iptvActionIndex-- - } else if (currentSection == "catalogs" && contentFocusIndex > 0 && catalogActionIndex > 0) { + } else if (currentSection == "catalogs" && contentFocusIndex > 1 && catalogActionIndex > 0) { catalogActionIndex-- } else { activeZone = Zone.SECTION @@ -789,7 +814,7 @@ fun SettingsScreen( iptvActionIndex++ } else if (currentSection == "iptv" && !showIptvCategoriesSettings && contentFocusIndex in 1..uiState.iptvPlaylists.size && iptvActionIndex < 5) { iptvActionIndex++ - } else if (currentSection == "catalogs" && contentFocusIndex > 0 && catalogActionIndex < 4) { + } else if (currentSection == "catalogs" && contentFocusIndex > 1 && catalogActionIndex < 5) { catalogActionIndex++ } } @@ -1021,8 +1046,10 @@ fun SettingsScreen( "catalogs" -> { if (contentFocusIndex == 0) { showCatalogInput = true + } else if (contentFocusIndex == 1) { + showCatalogPackInput = true } else { - val catalog = uiState.catalogs.getOrNull(contentFocusIndex - 1) + val catalog = uiState.catalogs.getOrNull(contentFocusIndex - 2) if (catalog != null) { when (catalogActionIndex) { 0 -> { @@ -1037,7 +1064,20 @@ fun SettingsScreen( toggleCatalogueRowLayoutMode(context, catalogueLayoutRowKey(catalog)) } } - else -> viewModel.removeCatalog(catalog.id) + 4 -> { + if (catalog.packId != null && catalog.isBulkDeletablePack) { + viewModel.unpackCatalog(catalog.id) + } + } + else -> { + if (catalog.isBulkDeletablePack) { + deletePackId = catalog.effectivePackId + deletePackName = catalog.effectivePackName + showDeletePackConfirm = true + } else { + viewModel.removeCatalog(catalog.id) + } + } } } } @@ -1128,11 +1168,21 @@ fun SettingsScreen( onAddIptvClick = { editingIptvIndex = -1; showIptvInput = true }, onEditIptvClick = { idx -> editingIptvIndex = idx; showIptvInput = true }, onAddCatalogClick = { showCatalogInput = true }, + onImportCatalogPackClick = { showCatalogPackInput = true }, onRenameCatalogClick = { catalog -> renameCatalogId = catalog.id renameCatalogTitle = catalog.title showCatalogRename = true }, + onDeleteCatalogClick = { catalog -> + if (catalog.isBulkDeletablePack) { + deletePackId = catalog.effectivePackId + deletePackName = catalog.effectivePackName + showDeletePackConfirm = true + } else { + viewModel.removeCatalog(catalog.id) + } + }, onConnectHomeServerClick = { val connection = uiState.homeServerConnection homeServerUrl = connection?.serverUrl.orEmpty() @@ -1532,6 +1582,7 @@ fun SettingsScreen( focusedIndex = if (activeZone == Zone.CONTENT) contentFocusIndex else -1, focusedActionIndex = catalogActionIndex, onAddCatalog = { showCatalogInput = true }, + onImportCatalogPack = { showCatalogPackInput = true }, onRenameCatalog = { catalog -> renameCatalogId = catalog.id renameCatalogTitle = catalog.title @@ -1539,7 +1590,16 @@ fun SettingsScreen( }, onMoveCatalogUp = { catalog -> viewModel.moveCatalogUp(catalog.id) }, onMoveCatalogDown = { catalog -> viewModel.moveCatalogDown(catalog.id) }, - onDeleteCatalog = { catalog -> viewModel.removeCatalog(catalog.id) } + onDeleteCatalog = { catalog -> + if (catalog.isBulkDeletablePack) { + deletePackId = catalog.effectivePackId + deletePackName = catalog.effectivePackName + showDeletePackConfirm = true + } else { + viewModel.removeCatalog(catalog.id) + } + }, + onUnpackCatalog = { catalog -> viewModel.unpackCatalog(catalog.id) } ) "stremio" -> StremioAddonsSettings( addons = stremioAddons, @@ -1819,6 +1879,53 @@ fun SettingsScreen( ) } + if (showCatalogPackInput) { + InputModal( + title = "Import Catalog Pack", + fields = listOf( + InputField(label = "Pack URL", value = catalogPackInputUrl, onValueChange = { catalogPackInputUrl = it }) + ), + onConfirm = { + if (catalogPackInputUrl.isNotBlank()) { + viewModel.loadPackManifest(catalogPackInputUrl) + catalogPackInputUrl = "" + showCatalogPackInput = false + } + }, + onDismiss = { + catalogPackInputUrl = "" + showCatalogPackInput = false + } + ) + } + + if (uiState.pendingPackManifest != null || uiState.isPackLoading || uiState.packError != null) { + CatalogPackImportDialog( + pendingPack = uiState.pendingPackManifest, + isLoading = uiState.isPackLoading, + error = uiState.packError, + onConfirm = { manifest -> + viewModel.confirmInstallPack(uiState.pendingPackUrl ?: "") + }, + onDismiss = { + viewModel.clearPendingPack() + } + ) + } + + if (showDeletePackConfirm) { + CatalogPackDeleteConfirmDialog( + packName = deletePackName, + onConfirm = { + viewModel.removeCatalogPack(deletePackId) + showDeletePackConfirm = false + }, + onDismiss = { + showDeletePackConfirm = false + } + ) + } + if (showQualityFiltersModal) { QualityFiltersModal( filters = uiState.qualityFilters, @@ -3277,7 +3384,9 @@ private fun MobileSettingsLayout( onAddIptvClick: () -> Unit, onEditIptvClick: (Int) -> Unit, onAddCatalogClick: () -> Unit, + onImportCatalogPackClick: () -> Unit, onRenameCatalogClick: (CatalogConfig) -> Unit, + onDeleteCatalogClick: (CatalogConfig) -> Unit, onConnectHomeServerClick: () -> Unit, onConnectPlexHomeServerClick: () -> Unit, onAddCustomAddonClick: () -> Unit, @@ -3371,7 +3480,9 @@ private fun MobileSettingsLayout( onAddIptvClick = onAddIptvClick, onEditIptvClick = onEditIptvClick, onAddCatalogClick = onAddCatalogClick, + onImportCatalogPackClick = onImportCatalogPackClick, onRenameCatalogClick = onRenameCatalogClick, + onDeleteCatalogClick = onDeleteCatalogClick, onConnectHomeServerClick = onConnectHomeServerClick, onConnectPlexHomeServerClick = onConnectPlexHomeServerClick, onAddCustomAddonClick = onAddCustomAddonClick, @@ -3581,7 +3692,9 @@ private fun MobileSettingsSubPage( onAddIptvClick: () -> Unit, onEditIptvClick: (Int) -> Unit, onAddCatalogClick: () -> Unit, + onImportCatalogPackClick: () -> Unit, onRenameCatalogClick: (CatalogConfig) -> Unit, + onDeleteCatalogClick: (CatalogConfig) -> Unit, onConnectHomeServerClick: () -> Unit, onConnectPlexHomeServerClick: () -> Unit, onAddCustomAddonClick: () -> Unit, @@ -3914,10 +4027,12 @@ private fun MobileSettingsSubPage( focusedIndex = -1, focusedActionIndex = 0, onAddCatalog = onAddCatalogClick, + onImportCatalogPack = onImportCatalogPackClick, onRenameCatalog = onRenameCatalogClick, onMoveCatalogUp = { viewModel.moveCatalogUp(it.id) }, onMoveCatalogDown = { viewModel.moveCatalogDown(it.id) }, - onDeleteCatalog = { viewModel.removeCatalog(it.id) } + onDeleteCatalog = onDeleteCatalogClick, + onUnpackCatalog = { viewModel.unpackCatalog(it.id) } ) } "TV" -> { @@ -7015,10 +7130,12 @@ private fun CatalogsSettings( focusedIndex: Int, focusedActionIndex: Int, onAddCatalog: () -> Unit, + onImportCatalogPack: () -> Unit, onRenameCatalog: (CatalogConfig) -> Unit, onMoveCatalogUp: (CatalogConfig) -> Unit, onMoveCatalogDown: (CatalogConfig) -> Unit, - onDeleteCatalog: (CatalogConfig) -> Unit + onDeleteCatalog: (CatalogConfig) -> Unit, + onUnpackCatalog: (CatalogConfig) -> Unit ) { val isMobile = LocalDeviceType.current.isTouchDevice() var selectionMode by remember { mutableStateOf(false) } @@ -7042,19 +7159,68 @@ private fun CatalogsSettings( } } MobileSettingsCategory(title = stringResource(R.string.settings_section_add_catalog)) { - MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = "", isFocused = false, showDivider = false, onClick = onAddCatalog) + MobileSettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = "", isFocused = false, showDivider = true, onClick = onAddCatalog) + MobileSettingsRow(icon = Icons.Default.Widgets, title = "Import Catalog Pack", subtitle = "Import a bundle of catalogs from a JSON manifest URL", value = "", isFocused = false, showDivider = false, onClick = onImportCatalogPack) } if (catalogs.isNotEmpty()) { MobileSettingsCategory(title = stringResource(R.string.settings_section_my_catalogs)) { catalogs.forEachIndexed { index, catalog -> val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> stringResource(R.string.settings_title_builtin_collection, catalog.title); CatalogKind.COLLECTION_RAIL -> stringResource(R.string.settings_title_builtin_rail, catalog.title); else -> stringResource(R.string.settings_title_builtin, catalog.title) } } else catalog.title + val currentPackId = catalog.packId + val prevPackId = if (index > 0) catalogs[index - 1].packId else null + val showPackHeader = currentPackId != null && currentPackId != prevPackId && catalog.isBulkDeletablePack val collectionFallback = stringResource(R.string.settings_collection_fallback) val addonFallback = stringResource(R.string.settings_source_addon) - val subtitle = when { catalog.kind == CatalogKind.COLLECTION_RAIL -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_rail, group) }; catalog.kind == CatalogKind.COLLECTION -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_collection, group) }; catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog); else -> when (catalog.sourceType) { CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback; stringResource(R.string.settings_from_source, addonLabel) }; CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server); else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) } } + val subtitle = run { + val baseSubtitle = when { + catalog.kind == CatalogKind.COLLECTION_RAIL -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_rail, group) + } + catalog.kind == CatalogKind.COLLECTION -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_collection, group) + } + catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog) + catalog.sourceType == CatalogSourceType.ADDON -> { + val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback + stringResource(R.string.settings_from_source, addonLabel) + } + catalog.sourceType == CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server) + else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) + } + "Pack: ${catalog.effectivePackName} • $baseSubtitle" + } val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL val layoutRowKey = remember(catalog.id, catalog.kind) { catalogueLayoutRowKey(catalog) } Column { + if (showPackHeader) { + if (index > 0) { + Spacer(modifier = Modifier.height(16.dp)) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Archive, + contentDescription = null, + tint = Pink, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = catalog.effectivePackName.uppercase(), + style = ArflixTypography.caption.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified), + color = Pink + ) + } + Box(modifier = Modifier.fillMaxWidth().height(1.dp).padding(horizontal = 16.dp).background(Pink.copy(alpha = 0.2f))) + Spacer(modifier = Modifier.height(4.dp)) + } Row( modifier = Modifier.fillMaxWidth() .background(if (isSelected) Pink.copy(alpha = 0.15f) else Color.Transparent) @@ -7079,6 +7245,23 @@ private fun CatalogsSettings( if (!selectionMode) { CatalogueRowLayoutToggleButton(rowKey = layoutRowKey, enabled = layoutToggleEnabled) Spacer(modifier = Modifier.width(8.dp)) + if (catalog.packId != null && catalog.isBulkDeletablePack) { + Box( + modifier = Modifier + .size(36.dp) + .clickable { onUnpackCatalog(catalog) } + .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Unarchive, + contentDescription = "Unpack catalog row", + tint = Color.White.copy(alpha = 0.7f), + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } } if (selectionMode && selectedIds.size == 1 && isSelected) { Icon(imageVector = Icons.Default.DragHandle, contentDescription = stringResource(R.string.settings_cd_drag_reorder), tint = TextSecondary, modifier = Modifier.size(24.dp).pointerInput(catalog.id) { @@ -7109,12 +7292,61 @@ private fun CatalogsSettings( Text(text = stringResource(R.string.catalogs), style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.65f), modifier = Modifier.padding(bottom = 20.dp)) SettingsRow(icon = Icons.Default.Add, title = stringResource(R.string.add_catalog), subtitle = stringResource(R.string.add_catalog_desc), value = stringResource(R.string.settings_badge_add), isFocused = focusedIndex == 0, onClick = onAddCatalog, modifier = Modifier.settingsFocusSlot(0)) Spacer(modifier = Modifier.height(16.dp)) + SettingsRow(icon = Icons.Default.Widgets, title = "Import Catalog Pack", subtitle = "Import a bundle of catalogs from a JSON manifest URL", value = "IMPORT", isFocused = focusedIndex == 1, onClick = onImportCatalogPack, modifier = Modifier.settingsFocusSlot(1)) + Spacer(modifier = Modifier.height(16.dp)) catalogs.forEachIndexed { index, catalog -> - val rowFocusIndex = index + 1; val isRowFocused = focusedIndex == rowFocusIndex + val rowFocusIndex = index + 2; val isRowFocused = focusedIndex == rowFocusIndex + val currentPackId = catalog.packId + val prevPackId = if (index > 0) catalogs[index - 1].packId else null + val showPackHeader = currentPackId != null && currentPackId != prevPackId && catalog.isBulkDeletablePack + + if (showPackHeader) { + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Archive, + contentDescription = null, + tint = Pink, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = catalog.effectivePackName.uppercase(), + style = ArflixTypography.caption.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Bold, letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified), + color = Pink + ) + } + Spacer(modifier = Modifier.height(8.dp)) + } + val title = if (catalog.isPreinstalled) { when (catalog.kind) { CatalogKind.COLLECTION -> stringResource(R.string.settings_title_builtin_collection, catalog.title); CatalogKind.COLLECTION_RAIL -> stringResource(R.string.settings_title_builtin_rail, catalog.title); else -> stringResource(R.string.settings_title_builtin, catalog.title) } } else catalog.title val collectionFallback = stringResource(R.string.settings_collection_fallback) val addonFallback = stringResource(R.string.settings_source_addon) - val subtitle = when { catalog.kind == CatalogKind.COLLECTION_RAIL -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_rail, group) }; catalog.kind == CatalogKind.COLLECTION -> { val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback; stringResource(R.string.settings_group_collection, group) }; catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog); else -> when (catalog.sourceType) { CatalogSourceType.ADDON -> { val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback; stringResource(R.string.settings_from_source, addonLabel) }; CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server); else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) } } + val subtitle = run { + val baseSubtitle = when { + catalog.kind == CatalogKind.COLLECTION_RAIL -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_rail, group) + } + catalog.kind == CatalogKind.COLLECTION -> { + val group = catalog.collectionGroup?.name?.lowercase()?.replaceFirstChar { it.uppercase() } ?: collectionFallback + stringResource(R.string.settings_group_collection, group) + } + catalog.sourceType == CatalogSourceType.PREINSTALLED -> stringResource(R.string.settings_preinstalled_catalog) + catalog.sourceType == CatalogSourceType.ADDON -> { + val addonLabel = catalog.addonName?.takeIf { it.isNotBlank() } ?: addonFallback + stringResource(R.string.settings_from_source, addonLabel) + } + catalog.sourceType == CatalogSourceType.HOME_SERVER -> stringResource(R.string.settings_from_home_server) + else -> catalog.sourceUrl ?: stringResource(R.string.settings_custom_catalog) + } + "Pack: ${catalog.effectivePackName} • $baseSubtitle" + } val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL val layoutRowKey = remember(catalog.id, catalog.kind) { catalogueLayoutRowKey(catalog) } @@ -7133,7 +7365,9 @@ private fun CatalogsSettings( Spacer(modifier = Modifier.width(6.dp)) CatalogueRowLayoutToggleButton(rowKey = layoutRowKey, enabled = layoutToggleEnabled, forceFocused = isRowFocused && focusedActionIndex == 3) Spacer(modifier = Modifier.width(6.dp)) - CatalogActionChip(icon = Icons.Default.Delete, isFocused = isRowFocused && focusedActionIndex == 4, isDestructive = true, enabled = true, onClick = { onDeleteCatalog(catalog) }) + CatalogActionChip(icon = Icons.Default.Unarchive, isFocused = isRowFocused && focusedActionIndex == 4, enabled = catalog.packId != null && catalog.isBulkDeletablePack, onClick = { onUnpackCatalog(catalog) }) + Spacer(modifier = Modifier.width(6.dp)) + CatalogActionChip(icon = Icons.Default.Delete, isFocused = isRowFocused && focusedActionIndex == 5, isDestructive = true, enabled = true, onClick = { onDeleteCatalog(catalog) }) } Spacer(modifier = Modifier.height(10.dp)) } @@ -9331,3 +9565,375 @@ private fun IptvCategoriesSettings( } } } + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun CatalogPackImportDialog( + pendingPack: CatalogPackManifest?, + isLoading: Boolean, + error: String?, + onConfirm: (CatalogPackManifest) -> Unit, + onDismiss: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + var focusedIndex by remember(pendingPack) { mutableIntStateOf(0) } // 0 = Confirm, 1 = Cancel/Dismiss + val isTouchDevice = LocalDeviceType.current.isTouchDevice() + + LaunchedEffect(pendingPack, isLoading, error) { + focusRequester.requestFocus() + } + + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .then( + if (isTouchDevice) Modifier.fillMaxWidth(0.92f).widthIn(max = 480.dp) + else Modifier.width(480.dp) + ) + .background(BackgroundElevated, RoundedCornerShape(16.dp)) + .padding(if (isTouchDevice) 20.dp else 28.dp) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Back, Key.Escape -> { + onDismiss() + true + } + Key.DirectionLeft -> { + if (focusedIndex > 0) focusedIndex-- + true + } + Key.DirectionRight -> { + if (focusedIndex < 1) focusedIndex++ + true + } + Key.Enter, Key.DirectionCenter -> { + if (isLoading) { + // Ignore clicks while loading + } else if (error != null) { + onDismiss() + } else if (pendingPack != null) { + if (focusedIndex == 0) { + onConfirm(pendingPack) + } else { + onDismiss() + } + } + true + } + else -> false + } + } else false + } + ) { + if (isLoading) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + LoadingIndicator(size = 40.dp) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Loading catalog pack details...", + style = ArflixTypography.body, + color = TextPrimary + ) + } + } else if (error != null) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Import Failed", + style = ArflixTypography.sectionTitle, + color = Color(0xFFDC2626) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = error, + style = ArflixTypography.body, + color = TextSecondary + ) + Spacer(modifier = Modifier.height(24.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White) + .clickable { onDismiss() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Close", + style = ArflixTypography.button, + color = Color.Black + ) + } + } + } else if (pendingPack != null) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Import Catalog Pack", + style = ArflixTypography.sectionTitle, + color = TextPrimary + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = pendingPack.name ?: "", + style = ArflixTypography.cardTitle.copy(fontSize = 18.sp), + color = resolveAccentColor(fallback = Pink) + ) + if (!pendingPack.author.isNullOrBlank()) { + Text( + text = "Author: ${pendingPack.author} • v${pendingPack.version ?: "1.0.0"}", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.8f) + ) + } + if (!pendingPack.description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = pendingPack.description, + style = ArflixTypography.body.copy(fontSize = 14.sp), + color = TextSecondary + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Catalogs included (${pendingPack.catalogs?.size ?: 0}):", + style = ArflixTypography.caption, + color = TextSecondary.copy(alpha = 0.6f) + ) + Spacer(modifier = Modifier.height(8.dp)) + // Display a preview list of catalogs (scrollable if many) + val previewScrollState = rememberScrollState() + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 160.dp) + .verticalScroll(previewScrollState) + .background(Color.Black.copy(alpha = 0.25f), RoundedCornerShape(8.dp)) + .padding(8.dp) + ) { + pendingPack.catalogs?.forEach { cat -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Widgets, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = cat.name ?: "", + style = ArflixTypography.body.copy(fontSize = 14.sp), + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + Spacer(modifier = Modifier.height(24.dp)) + // Action buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val isConfirmFocused = focusedIndex == 0 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isConfirmFocused) Color.White else Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(10.dp) + ) + .border( + width = 1.dp, + color = if (isConfirmFocused) Color.White else Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onConfirm(pendingPack) } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Install Pack", + style = ArflixTypography.button, + color = if (isConfirmFocused) Color.Black else Color.White + ) + } + + val isCancelFocused = focusedIndex == 1 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isCancelFocused) Color.White else Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(10.dp) + ) + .border( + width = 1.dp, + color = if (isCancelFocused) Color.White else Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onDismiss() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Cancel", + style = ArflixTypography.button, + color = if (isCancelFocused) Color.Black else Color.White + ) + } + } + } + } + } + } + } +} + +@OptIn(ExperimentalTvMaterial3Api::class) +@Composable +private fun CatalogPackDeleteConfirmDialog( + packName: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val focusRequester = remember { FocusRequester() } + var focusedIndex by remember { mutableIntStateOf(0) } // 0 = Confirm, 1 = Cancel + val isTouchDevice = LocalDeviceType.current.isTouchDevice() + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + androidx.compose.ui.window.Dialog( + onDismissRequest = onDismiss, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = true, + dismissOnClickOutside = true, + usePlatformDefaultWidth = false + ) + ) { + ModalScrim(onDismiss = onDismiss) { + Column( + modifier = Modifier + .then( + if (isTouchDevice) Modifier.fillMaxWidth(0.92f).widthIn(max = 400.dp) + else Modifier.width(400.dp) + ) + .background(BackgroundElevated, RoundedCornerShape(16.dp)) + .padding(if (isTouchDevice) 20.dp else 28.dp) + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyDown) { + when (event.key) { + Key.Back, Key.Escape -> { + onDismiss() + true + } + Key.DirectionLeft -> { + if (focusedIndex > 0) focusedIndex-- + true + } + Key.DirectionRight -> { + if (focusedIndex < 1) focusedIndex++ + true + } + Key.Enter, Key.DirectionCenter -> { + if (focusedIndex == 0) onConfirm() else onDismiss() + true + } + else -> false + } + } else false + } + ) { + Text( + text = "Delete Catalog Pack", + style = ArflixTypography.sectionTitle, + color = Color(0xFFDC2626) + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Do you want to delete the pack \"$packName\"? This will remove all catalogs that were imported with this pack.", + style = ArflixTypography.body, + color = TextSecondary + ) + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + val isConfirmFocused = focusedIndex == 0 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isConfirmFocused) Color.White else Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(10.dp) + ) + .border( + width = 1.dp, + color = if (isConfirmFocused) Color.White else Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onConfirm() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Delete Pack", + style = ArflixTypography.button, + color = if (isConfirmFocused) Color.Black else Color.White + ) + } + + val isCancelFocused = focusedIndex == 1 + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(10.dp)) + .background( + color = if (isCancelFocused) Color.White else Color.Black.copy(alpha = 0.82f), + shape = RoundedCornerShape(10.dp) + ) + .border( + width = 1.dp, + color = if (isCancelFocused) Color.White else Color.White.copy(alpha = 0.14f), + shape = RoundedCornerShape(10.dp) + ) + .clickable { onDismiss() } + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "Cancel", + style = ArflixTypography.button, + color = if (isCancelFocused) Color.Black else Color.White + ) + } + } + } + } + } +} 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 e5e9b6473..549416104 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 @@ -18,6 +18,7 @@ import com.arflix.tv.data.model.Addon import com.arflix.tv.data.model.CatalogConfig import com.arflix.tv.data.model.CatalogDiscoveryResult import com.arflix.tv.data.model.CatalogKind +import com.arflix.tv.data.model.CatalogPackManifest import com.arflix.tv.data.model.Profile import com.arflix.tv.data.model.QualityFilterConfig import com.arflix.tv.data.repository.AuthRepository @@ -169,6 +170,10 @@ data class SettingsUiState( val catalogSearchResults: List = emptyList(), val isCatalogSearching: Boolean = false, val catalogSearchError: String? = null, + val pendingPackManifest: CatalogPackManifest? = null, + val pendingPackUrl: String? = null, + val isPackLoading: Boolean = false, + val packError: String? = null, // Addons val addons: List = emptyList(), val torrServerBaseUrl: String = "", @@ -1780,6 +1785,84 @@ class SettingsViewModel @Inject constructor( } } + fun loadPackManifest(url: String) { + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isPackLoading = true, + packError = null, + pendingPackManifest = null, + pendingPackUrl = null + ) + val result = catalogRepository.fetchCatalogPackManifest(url) + result.onSuccess { manifest -> + _uiState.value = _uiState.value.copy( + isPackLoading = false, + pendingPackManifest = manifest, + pendingPackUrl = url + ) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isPackLoading = false, + packError = error.message ?: "Failed to load pack manifest", + pendingPackUrl = null + ) + } + } + } + + fun clearPendingPack() { + _uiState.value = _uiState.value.copy( + pendingPackManifest = null, + pendingPackUrl = null, + isPackLoading = false, + packError = null + ) + } + + fun confirmInstallPack(url: String) { + val manifest = _uiState.value.pendingPackManifest + viewModelScope.launch { + _uiState.value = _uiState.value.copy( + isPackLoading = true, + packError = null + ) + val result = catalogRepository.addCatalogPack(url, manifest) + result.onSuccess { installedManifest -> + _uiState.value = _uiState.value.copy( + isPackLoading = false, + pendingPackManifest = null, + pendingPackUrl = null, + toastMessage = "Installed pack: ${installedManifest.name}", + toastType = ToastType.SUCCESS + ) + syncLocalStateToCloud(silent = true) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + isPackLoading = false, + packError = error.message ?: "Failed to install pack" + ) + } + } + } + + fun removeCatalogPack(packId: String) { + viewModelScope.launch { + val result = catalogRepository.removeCatalogPack(packId) + result.onSuccess { + _uiState.value = _uiState.value.copy( + toastMessage = "Pack removed", + toastType = ToastType.SUCCESS + ) + syncLocalStateToCloud(silent = true) + }.onFailure { error -> + _uiState.value = _uiState.value.copy( + toastMessage = error.message ?: "Failed to remove pack", + toastType = ToastType.ERROR + ) + } + } + } + fun addCatalog(url: String) { viewModelScope.launch { val result = catalogRepository.addCustomCatalog(url) @@ -1907,6 +1990,30 @@ class SettingsViewModel @Inject constructor( } } + fun unpackCatalog(catalogId: String) { + viewModelScope.launch { + val current = catalogRepository.getCatalogs() + val index = current.indexOfFirst { it.id == catalogId } + if (index != -1) { + val target = current[index] + if (target.packId != null) { + val updated = current.toMutableList() + updated[index] = target.copy(packId = null, packName = null) + catalogRepository.replaceCatalogsForActiveProfile(updated) + + // Update state + val visible = visibleCatalogs(updated) + _uiState.value = _uiState.value.copy( + catalogs = visible, + toastMessage = "Catalog row extracted from pack", + toastType = ToastType.SUCCESS + ) + syncLocalStateToCloud(silent = true) + } + } + } + } + fun renameCatalog(catalogId: String, newTitle: String) { viewModelScope.launch { val success = catalogRepository.renameCatalog(catalogId, newTitle) diff --git a/app/src/test/kotlin/com/arflix/tv/data/repository/CatalogPackTest.kt b/app/src/test/kotlin/com/arflix/tv/data/repository/CatalogPackTest.kt new file mode 100644 index 000000000..0a1fd79d2 --- /dev/null +++ b/app/src/test/kotlin/com/arflix/tv/data/repository/CatalogPackTest.kt @@ -0,0 +1,174 @@ +package com.arflix.tv.data.repository + +import android.content.Context +import com.arflix.tv.data.model.CatalogConfig +import com.arflix.tv.data.model.CatalogPackManifest +import com.arflix.tv.data.model.CatalogPackItem +import com.arflix.tv.data.model.CatalogSourceType +import com.arflix.tv.data.model.CatalogValidationResult +import com.arflix.tv.data.api.TraktApi +import io.mockk.* +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import org.junit.Assert.* +import org.junit.Test + +class CatalogPackTest { + + @Test + fun `fetchCatalogPackManifest rejects duplicate catalog URLs`() = runBlocking { + val context = mockk(relaxed = true) + val profileManager = mockk(relaxed = true) + val traktApi = mockk(relaxed = true) + val okHttpClient = mockk(relaxed = true) + val invalidationBus = mockk(relaxed = true) + + val repository = spyk(CatalogRepository(context, profileManager, traktApi, okHttpClient, invalidationBus)) + + val duplicateManifestJson = """ + { + "id": "test-pack", + "name": "Test Pack", + "author": "Tester", + "version": "1.0.0", + "catalogs": [ + { + "name": "Catalog 1", + "url": "https://mdblist.com/lists/snoak/trending-movies" + }, + { + "name": "Catalog 2", + "url": "https://mdblist.com/lists/snoak/trending-movies/" + } + ] + } + """.trimIndent() + + coEvery { repository["fetchUrl"](any()) } returns duplicateManifestJson + + val result = repository.fetchCatalogPackManifest("https://example.com/pack.json") + assertTrue(result.isFailure) + val exception = result.exceptionOrNull() + assertNotNull(exception) + assertTrue(exception is IllegalArgumentException) + assertTrue(exception!!.message!!.contains("duplicate catalog URL")) + } + + @Test + fun `addCatalogPack derives unique pack ID from normalized manifest URL plus manifest ID`() = runBlocking { + val context = mockk(relaxed = true) + val profileManager = mockk(relaxed = true) + val traktApi = mockk(relaxed = true) + val okHttpClient = mockk(relaxed = true) + val invalidationBus = mockk(relaxed = true) + + val repository = spyk(CatalogRepository(context, profileManager, traktApi, okHttpClient, invalidationBus)) + + val addedCatalogs = mutableListOf() + coEvery { repository.getCatalogs() } returns emptyList() + coEvery { repository["saveCatalogs"](any>()) } answers { + addedCatalogs.addAll(firstArg>()) + } + + coEvery { repository.validateCatalogUrl(any()) } returns CatalogValidationResult( + isValid = true, + normalizedUrl = "https://mdblist.com/lists/snoak/trending-movies", + sourceType = CatalogSourceType.MDBLIST + ) + + val manifest = CatalogPackManifest( + id = "same-id", + name = "Test Pack", + author = "Tester", + version = "1.0.0", + description = "Desc", + catalogs = listOf( + CatalogPackItem(name = "Catalog 1", url = "https://mdblist.com/lists/snoak/trending-movies") + ) + ) + + val result1 = repository.addCatalogPack("https://example.com/pack1.json", manifest) + assertTrue(result1.isSuccess) + val packId1 = addedCatalogs[0].packId + assertNotNull(packId1) + + addedCatalogs.clear() + + val result2 = repository.addCatalogPack("https://example.com/pack2.json", manifest) + assertTrue(result2.isSuccess) + val packId2 = addedCatalogs[0].packId + assertNotNull(packId2) + + assertNotEquals(packId1, packId2) + } + + @Test + fun `addCatalogPack with pre-fetched manifest avoids network call`() = runBlocking { + val context = mockk(relaxed = true) + val profileManager = mockk(relaxed = true) + val traktApi = mockk(relaxed = true) + val okHttpClient = mockk(relaxed = true) + val invalidationBus = mockk(relaxed = true) + + val repository = spyk(CatalogRepository(context, profileManager, traktApi, okHttpClient, invalidationBus)) + + coEvery { repository.getCatalogs() } returns emptyList() + coEvery { repository["saveCatalogs"](any>()) } returns Unit + + coEvery { repository.validateCatalogUrl(any()) } returns CatalogValidationResult( + isValid = true, + normalizedUrl = "https://mdblist.com/lists/snoak/trending-movies", + sourceType = CatalogSourceType.MDBLIST + ) + + val manifest = CatalogPackManifest( + id = "test-pack", + name = "Test Pack", + author = "Tester", + version = "1.0.0", + description = "Desc", + catalogs = listOf( + CatalogPackItem(name = "Catalog 1", url = "https://mdblist.com/lists/snoak/trending-movies") + ) + ) + + val result = repository.addCatalogPack("https://example.com/pack.json", manifest) + assertTrue(result.isSuccess) + + coVerify(exactly = 0) { repository["fetchUrl"](any()) } + } + + @Test + fun `fetchCatalogPackManifest rejects malformed manifests`() = runBlocking { + val context = mockk(relaxed = true) + val profileManager = mockk(relaxed = true) + val traktApi = mockk(relaxed = true) + val okHttpClient = mockk(relaxed = true) + val invalidationBus = mockk(relaxed = true) + + val repository = spyk(CatalogRepository(context, profileManager, traktApi, okHttpClient, invalidationBus)) + + // 1. Missing ID + val missingIdJson = """ + { + "name": "Test Pack", + "catalogs": [{"name": "C1", "url": "https://example.com/c1"}] + } + """.trimIndent() + coEvery { repository["fetchUrl"](any()) } returns missingIdJson + val res1 = repository.fetchCatalogPackManifest("https://example.com/pack.json") + assertTrue(res1.isFailure) + + // 2. Empty catalogs list + val emptyCatalogsJson = """ + { + "id": "id", + "name": "Test Pack", + "catalogs": [] + } + """.trimIndent() + coEvery { repository["fetchUrl"](any()) } returns emptyCatalogsJson + val res2 = repository.fetchCatalogPackManifest("https://example.com/pack.json") + assertTrue(res2.isFailure) + } +} diff --git a/netlify-auth-site/netlify/database/migrations/20260721110000_catalog_packs/migration.sql b/netlify-auth-site/netlify/database/migrations/20260721110000_catalog_packs/migration.sql new file mode 100644 index 000000000..eeec714f8 --- /dev/null +++ b/netlify-auth-site/netlify/database/migrations/20260721110000_catalog_packs/migration.sql @@ -0,0 +1,23 @@ +CREATE TABLE IF NOT EXISTS public.catalog_packs ( + id uuid PRIMARY KEY DEFAULT (md5(random()::text || clock_timestamp()::text)::uuid), + name text NOT NULL, + url text NOT NULL, + author text, + version text DEFAULT '1.0.0', + description text, + catalogs jsonb NOT NULL, + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'approved')), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_catalog_packs_status ON public.catalog_packs(status); + +-- Seed approved catalog packs if not already present +INSERT INTO public.catalog_packs (id, name, url, author, version, description, catalogs, status) +VALUES + ('c0a37e5e-5b12-4eb0-a5ea-9d84c1737e51', 'Cinema Essentials', '/packs/cinema-essentials.json', 'ARVIO Team', '1.0.0', 'All the trending movies, popular lists, and upcoming releases you need for a perfect movie night.', '["Trending in Movies", "Top 10 Movies Today", "Top Movies This Week", "Coming Soon"]'::jsonb, 'approved'), + ('c0a37e5e-5b12-4eb0-a5ea-9d84c1737e52', 'TV Show Binge Pack', '/packs/tv-binge.json', 'ARVIO Team', '1.0.0', 'Never miss an episode. Popular, trending, and latest airing series in one convenient bundle.', '["Trending in Shows", "Top 10 Shows Today", "Latest Airing"]'::jsonb, 'approved'), + ('c0a37e5e-5b12-4eb0-a5ea-9d84c1737e53', 'Otaku & K-Drama Hub', '/packs/anime-kdrama.json', 'Community', '1.1.2', 'The ultimate pack for anime lovers and K-Drama fans. Auto-updated lists of trending episodes and releases.', '["Trending in Anime", "New in K-Dramas"]'::jsonb, 'approved'), + ('c0a37e5e-5b12-4eb0-a5ea-9d84c1737e54', 'Action & Franchise Classics', '/packs/classics-franchises.json', 'Cinephile', '1.0.5', 'Full box-sets and classic franchise collections, including James Bond, Harry Potter, Lord of the Rings, and Jurassic Park.', '["James Bond Collection", "Harry Potter Collection", "The Matrix Collection", "Lord of the Rings and Hobbit Collection", "Jurassic Park Collection"]'::jsonb, 'approved') +ON CONFLICT (id) DO NOTHING; diff --git a/netlify-auth-site/netlify/database/migrations/20260722120000_catalog_pack_submission_guards/migration.sql b/netlify-auth-site/netlify/database/migrations/20260722120000_catalog_pack_submission_guards/migration.sql new file mode 100644 index 000000000..1a7c29e17 --- /dev/null +++ b/netlify-auth-site/netlify/database/migrations/20260722120000_catalog_pack_submission_guards/migration.sql @@ -0,0 +1,26 @@ +ALTER TABLE public.catalog_packs + ADD COLUMN IF NOT EXISTS normalized_url text; + +UPDATE public.catalog_packs +SET normalized_url = lower( + rtrim( + CASE + WHEN url LIKE '/%' THEN 'https://arvio.app' || url + ELSE url + END, + '/' + ) +) +WHERE normalized_url IS NULL OR btrim(normalized_url) = ''; + +ALTER TABLE public.catalog_packs + ALTER COLUMN normalized_url SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_packs_normalized_url + ON public.catalog_packs(normalized_url); + +CREATE TABLE IF NOT EXISTS public.submission_rate_limits ( + ip_hash text PRIMARY KEY, + count integer NOT NULL DEFAULT 1, + last_submission_at timestamptz NOT NULL DEFAULT now() +); diff --git a/netlify-auth-site/netlify/functions/catalog-packs-list.js b/netlify-auth-site/netlify/functions/catalog-packs-list.js new file mode 100644 index 000000000..95e163cfb --- /dev/null +++ b/netlify-auth-site/netlify/functions/catalog-packs-list.js @@ -0,0 +1,21 @@ +const { + json, + options, + getPool +} = require("./_backend"); + +exports.handler = async (event) => { + const cors = options(event); + if (cors) return cors; + + try { + const result = await getPool().query( + "SELECT id, name, author, version, description, url, catalogs, status FROM public.catalog_packs WHERE status = $1 ORDER BY created_at DESC", + ["approved"] + ); + return json(200, result.rows); + } catch (error) { + console.error("catalog-packs-list failed", error); + return json(500, { error: "internal_error", message: error.message }); + } +}; diff --git a/netlify-auth-site/netlify/functions/catalog-packs-submit.js b/netlify-auth-site/netlify/functions/catalog-packs-submit.js new file mode 100644 index 000000000..d297f3484 --- /dev/null +++ b/netlify-auth-site/netlify/functions/catalog-packs-submit.js @@ -0,0 +1,376 @@ +const dns = require("dns").promises; +const https = require("https"); +const ipaddr = require("ipaddr.js"); +const { + json, + options, + parseBody, + getPool, + sha256 +} = require("./_backend"); + +const MAX_MANIFEST_BYTES = 512 * 1024; +const MANIFEST_TIMEOUT_MS = 4_000; +const MAX_REDIRECTS = 3; + +function isPrivateIp(ip) { + if (!ip) return true; + const cleaned = String(ip).trim().replace(/^\[|\]$/g, ""); + try { + // process() converts IPv4-mapped IPv6 addresses before classification. + return ipaddr.process(cleaned).range() !== "unicast"; + } catch { + return true; + } +} + +async function resolvePublicAddresses(hostname) { + const cleaned = hostname.trim().replace(/^\[|\]$/g, ""); + let addresses; + + if (ipaddr.isValid(cleaned)) { + const parsed = ipaddr.parse(cleaned); + addresses = [{ address: cleaned, family: parsed.kind() === "ipv6" ? 6 : 4 }]; + } else { + try { + addresses = await dns.lookup(hostname, { all: true, verbatim: true }); + } catch { + throw new Error(`DNS lookup failed for hostname: ${hostname}`); + } + } + + if (!Array.isArray(addresses) || addresses.length === 0) { + throw new Error(`DNS lookup returned no addresses for hostname: ${hostname}`); + } + if (addresses.some(({ address }) => isPrivateIp(address))) { + throw new Error("Access to private, loopback, or link-local IP addresses is blocked"); + } + return addresses; +} + +function requestPinnedHttps(url, resolvedAddress) { + return new Promise((resolve, reject) => { + let settled = false; + const fail = (error) => { + if (settled) return; + settled = true; + reject(error); + }; + + const request = https.request(url, { + method: "GET", + agent: false, + headers: { "User-Agent": "ARVIO-Pack-Validator/1.0" }, + // Pin the connection to the address that passed validation. TLS still + // verifies the certificate against the original URL hostname. + lookup: (_hostname, options, callback) => { + if (options && options.all) { + callback(null, [resolvedAddress]); + } else { + callback(null, resolvedAddress.address, resolvedAddress.family); + } + } + }, (response) => { + const contentLength = Number(response.headers["content-length"] || 0); + if (contentLength > MAX_MANIFEST_BYTES) { + fail(new Error("Response size exceeds the maximum limit of 512 KB")); + response.destroy(); + return; + } + + const chunks = []; + let totalBytes = 0; + response.on("data", (chunk) => { + if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > MAX_MANIFEST_BYTES) { + fail(new Error("Response size exceeds the maximum limit of 512 KB")); + response.destroy(); + request.destroy(); + return; + } + chunks.push(buffer); + }); + response.on("error", fail); + response.on("end", () => { + if (settled) return; + settled = true; + resolve({ + statusCode: response.statusCode || 0, + headers: response.headers, + body: Buffer.concat(chunks) + }); + }); + }); + + request.setTimeout(MANIFEST_TIMEOUT_MS, () => { + const error = new Error("Request timed out"); + error.code = "ETIMEDOUT"; + fail(error); + request.destroy(); + }); + request.on("error", fail); + request.end(); + }); +} + +let secureRequest = requestPinnedHttps; + +// Normalize URL helper +function normalizeManifestUrl(urlStr) { + let trimmed = urlStr.trim(); + if (trimmed.endsWith("/")) { + trimmed = trimmed.slice(0, -1); + } + return trimmed.toLowerCase(); +} + +async function fetchSecure(urlStr) { + let currentUrl = urlStr; + let redirects = 0; + + while (true) { + let parsedUrl; + try { + parsedUrl = new URL(currentUrl); + } catch { + throw new Error("Invalid URL format"); + } + if (parsedUrl.protocol !== "https:") { + throw new Error("Only HTTPS manifest URLs are allowed"); + } + + const hostname = parsedUrl.hostname; + if (!hostname) { + throw new Error("Invalid hostname"); + } + + const addresses = await resolvePublicAddresses(hostname); + let response; + try { + response = await secureRequest(parsedUrl, addresses[0]); + } catch (err) { + if (err.code === "ETIMEDOUT" || /timed out/i.test(err.message || "")) { + throw new Error("Request timed out"); + } + throw new Error(`Failed to fetch manifest: ${err.message}`); + } + + if ([301, 302, 303, 307, 308].includes(response.statusCode)) { + if (redirects >= MAX_REDIRECTS) { + throw new Error("Too many redirects"); + } + const location = response.headers.location; + if (!location) { + throw new Error("Redirect response missing location header"); + } + currentUrl = new URL(location, currentUrl).toString(); + redirects++; + continue; + } + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error(`Server returned status ${response.statusCode}`); + } + + const contentType = String(response.headers["content-type"] || ""); + if (!contentType.includes("application/json") && !contentType.includes("text/plain")) { + throw new Error("Response content-type must be JSON"); + } + + const contentLength = Number(response.headers["content-length"] || 0); + if (contentLength > MAX_MANIFEST_BYTES || response.body.length > MAX_MANIFEST_BYTES) { + throw new Error("Response size exceeds the maximum limit of 512 KB"); + } + + try { + return JSON.parse(response.body.toString("utf8")); + } catch (e) { + throw new Error("Failed to parse manifest as JSON. Please ensure it is valid JSON."); + } + } +} + +exports.handler = async (event) => { + const cors = options(event); + if (cors) return cors; + + if (event.httpMethod !== "POST") { + return json(405, { error: "method_not_allowed", message: "Method not allowed" }); + } + + try { + const body = parseBody(event); + const manifestUrl = (body.url || "").trim(); + + if (!manifestUrl) { + return json(400, { error: "bad_request", message: "Manifest URL is required" }); + } + + if (!manifestUrl.startsWith("https://")) { + return json(400, { error: "bad_request", message: "Manifest URL must start with https://" }); + } + + // Rate Limiting Check + const clientIp = event.headers["x-nf-client-connection-ip"] || + event.headers["client-ip"] || + event.headers["x-forwarded-for"] || + "unknown-ip"; + + const ipHash = sha256(clientIp); + + // Create rate limit table if not exists (safety check) + await getPool().query(` + CREATE TABLE IF NOT EXISTS public.submission_rate_limits ( + ip_hash text PRIMARY KEY, + count integer NOT NULL DEFAULT 1, + last_submission_at timestamptz NOT NULL DEFAULT now() + ); + `).catch(err => console.warn("Failed to create submission_rate_limits table", err)); + + // Prune entries older than 1 hour + await getPool().query( + "DELETE FROM public.submission_rate_limits WHERE last_submission_at < now() - interval '1 hour'" + ).catch(err => console.warn("Failed to prune rate limits", err)); + + const rateLimitRes = await getPool().query( + "SELECT count, last_submission_at FROM public.submission_rate_limits WHERE ip_hash = $1", + [ipHash] + ); + + if (rateLimitRes.rows.length > 0) { + const { count, last_submission_at } = rateLimitRes.rows[0]; + const lastTime = new Date(last_submission_at).getTime(); + const now = Date.now(); + + // Double-click defense (10s) + if (now - lastTime < 10000) { + return json(429, { + error: "rate_limited", + message: "Too many requests. Please wait a few seconds before trying again." + }); + } + + // Hourly limit check (5 submissions max per hour) + if (count >= 5) { + return json(429, { + error: "rate_limited", + message: "Submission limit exceeded. Please try again in an hour." + }); + } + + await getPool().query( + "UPDATE public.submission_rate_limits SET count = count + 1, last_submission_at = now() WHERE ip_hash = $1", + [ipHash] + ); + } else { + await getPool().query( + "INSERT INTO public.submission_rate_limits (ip_hash, count, last_submission_at) VALUES ($1, 1, now())", + [ipHash] + ); + } + + // Check for duplicate manifest URL in DB + const normalizedUrl = normalizeManifestUrl(manifestUrl); + const duplicateCheck = await getPool().query( + "SELECT id FROM public.catalog_packs WHERE normalized_url = $1", + [normalizedUrl] + ); + + if (duplicateCheck.rows.length > 0) { + return json(409, { + error: "conflict", + message: "This catalog pack manifest URL is already submitted." + }); + } + + // Securely fetch and validate manifest content + let manifest; + try { + manifest = await fetchSecure(manifestUrl); + } catch (fetchErr) { + return json(400, { + error: "fetch_failed", + message: fetchErr.message + }); + } + + // Validate manifest structure + if (!manifest.id || typeof manifest.id !== "string" || !manifest.id.trim()) { + return json(400, { error: "invalid_manifest", message: "Manifest is missing a valid 'id'." }); + } + if (!manifest.name || typeof manifest.name !== "string" || !manifest.name.trim()) { + return json(400, { error: "invalid_manifest", message: "Manifest is missing a valid 'name'." }); + } + if (!Array.isArray(manifest.catalogs) || manifest.catalogs.length === 0) { + return json(400, { error: "invalid_manifest", message: "Manifest must contain a non-empty 'catalogs' list." }); + } + + // Validate catalog list and check for duplicate URLs within manifest + const innerUrls = new Set(); + const catalogsList = []; + + for (const item of manifest.catalogs) { + if (!item.name || typeof item.name !== "string" || !item.name.trim()) { + return json(400, { + error: "invalid_manifest", + message: "All catalog items in the manifest must have a valid 'name'." + }); + } + if (!item.url || typeof item.url !== "string" || !item.url.trim()) { + return json(400, { + error: "invalid_manifest", + message: "All catalog items in the manifest must have a valid 'url'." + }); + } + + let trimmed = item.url.trim(); + let withScheme = trimmed.startsWith("http://") || trimmed.startsWith("https://") + ? trimmed + : `https://${trimmed}`; + if (withScheme.endsWith("/")) { + withScheme = withScheme.slice(0, -1); + } + const innerNorm = withScheme.toLowerCase(); + + if (innerUrls.has(innerNorm)) { + return json(400, { + error: "invalid_manifest", + message: `Duplicate catalog URL found inside manifest: '${item.url}'.` + }); + } + innerUrls.add(innerNorm); + catalogsList.push(item.name.trim()); + } + + // Insert into database + const name = (manifest.name || "").trim(); + const author = (body.author || manifest.author || "Anonymous").trim(); + const version = (manifest.version || "1.0.0").trim(); + const description = (body.description || manifest.description || "").trim(); + + const insertResult = await getPool().query( + `INSERT INTO public.catalog_packs (name, url, normalized_url, author, version, description, catalogs, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, name, url, normalized_url, author, version, description, catalogs, status`, + [name, manifestUrl, normalizedUrl, author, version, description, JSON.stringify(catalogsList), "pending"] + ); + + return json(201, insertResult.rows[0]); + } catch (error) { + console.error("catalog-packs-submit failed", error); + return json(500, { error: "internal_error", message: error.message }); + } +}; + +exports._test = { + fetchSecure, + isPrivateIp, + setSecureRequest(requester) { + secureRequest = requester; + }, + resetSecureRequest() { + secureRequest = requestPinnedHttps; + } +}; diff --git a/netlify-auth-site/package-lock.json b/netlify-auth-site/package-lock.json index dfa82d908..746b882e0 100644 --- a/netlify-auth-site/package-lock.json +++ b/netlify-auth-site/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@netlify/blobs": "^10.7.9", "@netlify/database": "^1.0.0", + "ipaddr.js": "^2.4.0", "pg": "^8.16.3" }, "devDependencies": { @@ -9791,7 +9792,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10" diff --git a/netlify-auth-site/package.json b/netlify-auth-site/package.json index a896f304f..38fcf3164 100644 --- a/netlify-auth-site/package.json +++ b/netlify-auth-site/package.json @@ -14,6 +14,7 @@ "dependencies": { "@netlify/blobs": "^10.7.9", "@netlify/database": "^1.0.0", + "ipaddr.js": "^2.4.0", "pg": "^8.16.3" }, "devDependencies": { diff --git a/netlify-auth-site/tests/catalog-packs-submit.test.js b/netlify-auth-site/tests/catalog-packs-submit.test.js new file mode 100644 index 000000000..b56fdc3dd --- /dev/null +++ b/netlify-auth-site/tests/catalog-packs-submit.test.js @@ -0,0 +1,236 @@ +const test = require("node:test"); +const assert = require("assert"); +const dns = require("dns").promises; + +// Mock the backend module before requiring the handler to avoid connecting to the database +const mockQueries = []; +let mockRateLimitRows = []; +let mockDuplicateCheckRows = []; + +const mockBackend = { + json: (status, body) => ({ + statusCode: status, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body) + }), + options: () => null, + parseBody: (event) => { + return typeof event.body === "string" ? JSON.parse(event.body) : event.body; + }, + getPool: () => ({ + query: async (queryText, values) => { + mockQueries.push({ queryText, values }); + if (queryText.includes("submission_rate_limits") && queryText.includes("SELECT")) { + return { rows: mockRateLimitRows }; + } + if (queryText.includes("catalog_packs") && queryText.includes("SELECT")) { + return { rows: mockDuplicateCheckRows }; + } + return { rows: [{ id: "mock-id", name: "Mock Pack" }] }; + } + }), + sha256: (val) => require("crypto").createHash("sha256").update(val).digest("hex") +}; + +require.cache[require.resolve("../netlify/functions/_backend")] = { + exports: mockBackend +}; + +const { handler, _test } = require("../netlify/functions/catalog-packs-submit"); + +// Helper to create events +function createEvent({ url, clientIp = "1.2.3.4", bodyExtra = {} }) { + return { + httpMethod: "POST", + headers: { + "client-ip": clientIp + }, + body: JSON.stringify({ + url, + ...bodyExtra + }) + }; +} + +test("catalog-packs-submit unit tests", async (t) => { + t.afterEach(() => { + _test.resetSecureRequest(); + mockQueries.length = 0; + mockRateLimitRows = []; + mockDuplicateCheckRows = []; + }); + + await t.test("Allow HTTPS manifest URLs only", async () => { + const event = createEvent({ url: "http://example.com/manifest.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "bad_request"); + assert.match(body.message, /https:\/\//i); + }); + + await t.test("Block localhost, private and link-local IPs (direct IP check)", async () => { + const localhostEvent = createEvent({ url: "https://127.0.0.1/manifest.json" }); + const res = await handler(localhostEvent); + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "fetch_failed"); + assert.match(body.message, /private, loopback, or link-local IP addresses/); + }); + + await t.test("Block IPv4-mapped IPv6 loopback addresses", async () => { + const event = createEvent({ url: "https://[::ffff:127.0.0.1]/manifest.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "fetch_failed"); + assert.match(body.message, /private, loopback, or link-local IP addresses/); + }); + + await t.test("Block hostnames that resolve to private IPs (DNS resolution check)", async () => { + // Resolve any test local hostname or stub DNS lookup. + // For safety in this test, we can mock dns.lookup or mock a local URL + const originalLookup = dns.lookup; + dns.lookup = async () => [{ address: "192.168.1.50", family: 4 }]; + + try { + const event = createEvent({ url: "https://some-internal-domain.local/manifest.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "fetch_failed"); + assert.match(body.message, /private, loopback, or link-local IP addresses/); + } finally { + dns.lookup = originalLookup; + } + }); + + await t.test("Block a hostname when any DNS result is private", async () => { + const originalLookup = dns.lookup; + dns.lookup = async () => [ + { address: "93.184.216.34", family: 4 }, + { address: "10.0.0.8", family: 4 } + ]; + + try { + const event = createEvent({ url: "https://mixed-addresses.example/manifest.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "fetch_failed"); + assert.match(body.message, /private, loopback, or link-local IP addresses/); + } finally { + dns.lookup = originalLookup; + } + }); + + await t.test("Pin the request to the validated DNS address", async () => { + const originalLookup = dns.lookup; + dns.lookup = async () => [{ address: "93.184.216.34", family: 4 }]; + let requestedAddress; + _test.setSecureRequest(async (_url, address) => { + requestedAddress = address; + return { + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ + id: "safe-pack", + name: "Safe Pack", + catalogs: [{ name: "Movies", url: "https://mdblist.com/lists/test/movies" }] + })) + }; + }); + + try { + const event = createEvent({ url: "https://public-manifest.example/manifest.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 201); + assert.deepStrictEqual(requestedAddress, { address: "93.184.216.34", family: 4 }); + } finally { + dns.lookup = originalLookup; + } + }); + + await t.test("Fail on response size exceeding maximum limit", async () => { + _test.setSecureRequest(async () => ({ + statusCode: 200, + headers: { + "content-type": "application/json", + "content-length": String(1024 * 1024) + }, + body: Buffer.alloc(0) + })); + + const event = createEvent({ url: "https://93.184.216.34/huge.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "fetch_failed"); + assert.match(body.message, /exceeds the maximum limit/); + }); + + await t.test("Fail on fetch timeout", async () => { + _test.setSecureRequest(async () => { + const error = new Error("Request timed out"); + error.code = "ETIMEDOUT"; + throw error; + }); + + const event = createEvent({ url: "https://93.184.216.34/slow.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "fetch_failed"); + assert.match(body.message, /timed out/); + }); + + await t.test("Fail on invalid JSON content-type", async () => { + _test.setSecureRequest(async () => ({ + statusCode: 200, + headers: { "content-type": "text/html" }, + body: Buffer.from("") + })); + + const event = createEvent({ url: "https://93.184.216.34/index.html" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 400); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "fetch_failed"); + assert.match(body.message, /content-type must be JSON/); + }); + + await t.test("Reject duplicate manifest URLs", async () => { + mockDuplicateCheckRows = [{ id: "existing-id" }]; + + const event = createEvent({ url: "https://example.com/duplicate.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 409); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "conflict"); + assert.match(body.message, /already submitted/); + }); + + await t.test("Enforce hourly rate limit", async () => { + // Mock database returning 5 submissions for this IP in the current hour + mockRateLimitRows = [{ count: 5, last_submission_at: new Date(Date.now() - 300000).toISOString() }]; + + const event = createEvent({ url: "https://example.com/ok.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 429); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "rate_limited"); + assert.match(body.message, /Submission limit exceeded/); + }); + + await t.test("Enforce double-click defense (10s)", async () => { + // Mock database returning 1 submission 5 seconds ago + mockRateLimitRows = [{ count: 1, last_submission_at: new Date(Date.now() - 5000).toISOString() }]; + + const event = createEvent({ url: "https://example.com/ok.json" }); + const res = await handler(event); + assert.strictEqual(res.statusCode, 429); + const body = JSON.parse(res.body); + assert.strictEqual(body.error, "rate_limited"); + assert.match(body.message, /wait a few seconds/); + }); +}); diff --git a/web/app/discover/page.tsx b/web/app/discover/page.tsx new file mode 100644 index 000000000..c08fbcab4 --- /dev/null +++ b/web/app/discover/page.tsx @@ -0,0 +1,921 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Copy, Check, Download, HelpCircle, Code, ListVideo, Loader2, Plus, X, Globe, User, BookOpen, AlertCircle } from "lucide-react"; +import { config } from "@/lib/config"; + +export const dynamic = "force-static"; + +interface CatalogPack { + id?: string; + name: string; + author: string; + version: string; + description: string; + url: string; + catalogs: string[]; + status?: "pending" | "approved"; +} + +const STATIC_FALLBACK_PACKS: CatalogPack[] = [ + { + id: "cinema-essentials", + name: "Cinema Essentials", + author: "ARVIO Team", + version: "1.0.0", + description: "All the trending movies, popular lists, and upcoming releases you need for a perfect movie night.", + url: "/packs/cinema-essentials.json", + catalogs: ["Trending in Movies", "Top 10 Movies Today", "Top Movies This Week", "Coming Soon"] + }, + { + id: "tv-binge", + name: "TV Show Binge Pack", + author: "ARVIO Team", + version: "1.0.0", + description: "Never miss an episode. Popular, trending, and latest airing series in one convenient bundle.", + url: "/packs/tv-binge.json", + catalogs: ["Trending in Shows", "Top 10 Shows Today", "Latest Airing"] + }, + { + id: "anime-kdrama", + name: "Otaku & K-Drama Hub", + author: "Community", + version: "1.1.2", + description: "The ultimate pack for anime lovers and K-Drama fans. Auto-updated lists of trending episodes and releases.", + url: "/packs/anime-kdrama.json", + catalogs: ["Trending in Anime", "New in K-Dramas"] + }, + { + id: "classics-franchises", + name: "Action & Franchise Classics", + author: "Cinephile", + version: "1.0.5", + description: "Full box-sets and classic franchise collections, including James Bond, Harry Potter, Lord of the Rings, and Jurassic Park.", + url: "/packs/classics-franchises.json", + catalogs: [ + "James Bond Collection", + "Harry Potter Collection", + "The Matrix Collection", + "Lord of the Rings and Hobbit Collection", + "Jurassic Park Collection" + ] + } +]; + +export default function DiscoverPage() { + const [packs, setPacks] = useState(STATIC_FALLBACK_PACKS); + const [loading, setLoading] = useState(true); + const [copiedId, setCopiedId] = useState(null); + + // Form States + const [showSubmitModal, setShowSubmitModal] = useState(false); + const [formName, setFormName] = useState(""); + const [formAuthor, setFormAuthor] = useState(""); + const [formUrl, setFormUrl] = useState(""); + const [formDesc, setFormDesc] = useState(""); + const [submitLoading, setSubmitLoading] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitSuccess, setSubmitSuccess] = useState(false); + + async function loadPacks() { + try { + const res = await fetch(`${config.netlifyBackendUrl}/catalog-packs-list`); + if (res.ok) { + const data = await res.json(); + const parsed = data.map((item: any) => ({ + ...item, + catalogs: typeof item.catalogs === "string" ? JSON.parse(item.catalogs) : item.catalogs + })); + if (Array.isArray(parsed) && parsed.length > 0) { + setPacks(parsed); + setLoading(false); + return; + } + } + } catch (err) { + console.warn("Netlify backend packs fetch failed, falling back to static packs.json:", err); + } + + // Fallback: fetch from /packs.json + try { + const res = await fetch("/packs.json"); + if (res.ok) { + const data = await res.json(); + if (Array.isArray(data) && data.length > 0) { + setPacks(data); + } + } + } catch (err) { + console.error("Local packs.json fetch failed, using default hardcoded list:", err); + } + setLoading(false); + } + + useEffect(() => { + void loadPacks(); + }, []); + + const getAbsoluteUrl = (url: string) => { + if (!url) return ""; + if (url.startsWith("/")) { + if (typeof window !== "undefined") { + return `${window.location.origin}${url}`; + } + return `https://arvio.app${url}`; + } + return url; + }; + + const copyToClipboard = (id: string, text: string) => { + navigator.clipboard.writeText(text).then(() => { + setCopiedId(id); + setTimeout(() => setCopiedId(null), 2000); + }); + }; + + const getDeepLink = (packUrl: string) => { + return `arvio://install-pack?url=${encodeURIComponent(getAbsoluteUrl(packUrl))}`; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formName.trim() || !formUrl.trim() || !formDesc.trim()) { + setSubmitError("Please fill out all required fields."); + return; + } + + if (!formUrl.startsWith("https://")) { + setSubmitError("Manifest URL must start with https://"); + return; + } + + setSubmitLoading(true); + setSubmitError(null); + + try { + const res = await fetch(`${config.netlifyBackendUrl}/catalog-packs-submit`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + name: formName.trim(), + url: formUrl.trim(), + author: formAuthor.trim() || "Anonymous", + description: formDesc.trim() + }) + }); + + if (!res.ok) { + const errorJson = await res.json().catch(() => null); + throw new Error(errorJson?.message || `Failed to submit: ${res.statusText}`); + } + + setSubmitSuccess(true); + setFormName(""); + setFormAuthor(""); + setFormUrl(""); + setFormDesc(""); + setTimeout(() => { + setSubmitSuccess(false); + setShowSubmitModal(false); + }, 3000); + } catch (err: any) { + setSubmitError(err.message || "An unexpected error occurred during submission."); + } finally { + setSubmitLoading(false); + } + }; + + return ( +
+
+
+
+ ARVIO Logo + ARVIO +
+ +
+ +

Catalog Pack Discovery

+

+ Enhance your streaming layout. Browse community-curated catalog packs and install multiple rows of content with a single click. +

+
+ + {loading ? ( +
+ + Fetching latest catalog packs... +
+ ) : ( +
+ {packs.map((pack) => ( +
+
+

{pack.name}

+
+ by {pack.author || "Anonymous"} + v{pack.version || "1.0.0"} +
+
+

{pack.description}

+ +
+

+ Included Rows ({pack.catalogs?.length || 0}) +

+
    + {pack.catalogs?.map((catalog, idx) => ( +
  • {catalog}
  • + ))} +
+
+ +
+ + Install Pack + + +
+
+ ))} +
+ )} + + {/* Submission Modal Overlay */} + {showSubmitModal && ( +
+
+ + + {submitSuccess ? ( +
+ +

Submission Successful!

+

+ Thank you! Your catalog pack has been submitted for review. It will become visible on the Discovery page once approved by a moderator. +

+
+ ) : ( +
+

Submit a Catalog Pack

+

+ Packs are moderated to ensure safety, formatting correctness, and server reliability. +

+ + {submitError && ( +
+ + {submitError} +
+ )} + +
+ + setFormName(e.target.value)} + /> +
+ +
+ + setFormUrl(e.target.value)} + /> + Must be a public HTTPS URL that serves valid JSON. +
+ +
+
+ + setFormAuthor(e.target.value)} + /> +
+
+ +
+ +