diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 687ad58c..679c0053 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -42,6 +42,22 @@ android:host="auth" android:path="/trakt" /> + + + + + + + + + + + + + + + + { + selectedTab = AppScreenTab.Settings + navController.navigate(AddonsSettingsRoute) { + launchSingleTop = true + } + NuvioToastController.show(getString(Res.string.addons_modal_checking_title)) + AddonRepository.initialize() + when (val result = AddonRepository.addAddon(deepLink.manifestUrl)) { + is AddAddonResult.Success -> { + NuvioToastController.show( + getString(Res.string.addons_modal_success_message, result.manifest.name), + ) + } + + is AddAddonResult.Error -> { + NuvioToastController.show(result.message) + } + } + AppDeepLinkRepository.markConsumed(deepLink) + } + AppDeepLink.Downloads -> { selectedTab = AppScreenTab.Settings navController.navigate(DownloadsSettingsRoute) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt index e36e3934..4089f5f1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/deeplink/AppUrlBridge.kt @@ -7,16 +7,20 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -sealed interface AppDeepLink { +internal sealed interface AppDeepLink { data class Meta( val type: String, val id: String, ) : AppDeepLink + data class AddonInstall( + val manifestUrl: String, + ) : AppDeepLink + data object Downloads : AppDeepLink } -object AppDeepLinkRepository { +internal object AppDeepLinkRepository { private val _pendingDeepLink = MutableStateFlow(null) val pendingDeepLink: StateFlow = _pendingDeepLink.asStateFlow() @@ -53,19 +57,118 @@ fun buildMetaDeepLinkUrl( fun buildDownloadsDeepLinkUrl(): String = "nuvio://downloads" -private fun parseAppDeepLink(url: String): AppDeepLink? { +internal fun parseAppDeepLink(url: String): AppDeepLink? { val parsedUrl = runCatching { Url(url) }.getOrNull() ?: return null - if (!parsedUrl.protocol.name.equals("nuvio", ignoreCase = true)) return null + val scheme = parsedUrl.protocol.name.lowercase() + if (scheme == "stremio") { + return if (looksLikeAddonHost(parsedUrl.host.lowercase())) { + customSchemeToHttpsUrl(url, scheme)?.let(AppDeepLink::AddonInstall) + } else { + null + } + } + if (scheme != "nuvio") return null - return when (parsedUrl.host.lowercase()) { + val host = parsedUrl.host.lowercase() + val pathSegments = parsedUrl.pathSegments.map(String::trim).filter(String::isNotBlank) + return when (host) { "meta" -> { - val type = parsedUrl.parameters["type"]?.trim().orEmpty() - val id = parsedUrl.parameters["id"]?.trim().orEmpty() + parseMetaFromParameters(parsedUrl) + ?: parseMetaFromPath(pathSegments) + } + + "detail", "details", "open", "watch" -> parseMetaFromPath(pathSegments) + + "movie", "movies", "series", "show", "shows", "tv" -> { + val type = normalizeDeepLinkMediaType(host) + val id = pathSegments.firstOrNull()?.let(::normalizeDeepLinkId).orEmpty() if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) } + "imdb", "tmdb" -> parseProviderMetaDeepLink(host, pathSegments, parsedUrl) + "downloads" -> AppDeepLink.Downloads - else -> null + "auth" -> null + + else -> { + if (looksLikeAddonHost(host)) { + customSchemeToHttpsUrl(url, scheme)?.let(AppDeepLink::AddonInstall) + } else { + null + } + } } -} \ No newline at end of file +} + +private fun parseMetaFromParameters(parsedUrl: Url): AppDeepLink.Meta? { + val type = firstParameter(parsedUrl, "type", "mediaType", "media_type") + ?.let(::normalizeDeepLinkMediaType) + .orEmpty() + val id = firstParameter(parsedUrl, "id", "imdb", "imdbId", "imdb_id") + ?.let(::normalizeDeepLinkId) + ?: firstParameter(parsedUrl, "tmdb", "tmdbId", "tmdb_id") + ?.let { "tmdb:${it.removePrefix("tmdb:").trim()}" } + ?: "" + return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) +} + +private fun parseMetaFromPath(pathSegments: List): AppDeepLink.Meta? { + if (pathSegments.size < 2) return null + val type = normalizeDeepLinkMediaType(pathSegments[0]) + val id = normalizeDeepLinkId(pathSegments[1]) + return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) +} + +private fun parseProviderMetaDeepLink( + provider: String, + pathSegments: List, + parsedUrl: Url, +): AppDeepLink.Meta? { + val first = pathSegments.firstOrNull().orEmpty() + val second = pathSegments.getOrNull(1).orEmpty() + val firstAsType = normalizeDeepLinkMediaType(first) + val queryType = firstParameter(parsedUrl, "type", "mediaType", "media_type") + ?.let(::normalizeDeepLinkMediaType) + .orEmpty() + val type = firstAsType.ifBlank { queryType } + val rawId = if (firstAsType.isNotBlank()) second else first + val id = when (provider) { + "tmdb" -> rawId.removePrefix("tmdb:").trim().takeIf(String::isNotBlank)?.let { "tmdb:$it" } + else -> normalizeDeepLinkId(rawId).takeIf(String::isNotBlank) + }.orEmpty() + return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id) +} + +private fun firstParameter(parsedUrl: Url, vararg keys: String): String? = + keys.firstNotNullOfOrNull { key -> + parsedUrl.parameters[key]?.trim()?.takeIf(String::isNotBlank) + } + +private fun normalizeDeepLinkMediaType(value: String): String = + when (value.trim().lowercase()) { + "movie", "movies", "film", "films" -> "movie" + "series", "show", "shows", "tv", "tvshow", "tvshows" -> "series" + else -> "" + } + +private fun normalizeDeepLinkId(value: String): String = + value.trim() + .removePrefix("imdb:") + .takeIf(String::isNotBlank) + .orEmpty() + +private fun looksLikeAddonHost(host: String): Boolean = + host.contains('.') || + host.equals("localhost", ignoreCase = true) || + host.any(Char::isDigit) + +private fun customSchemeToHttpsUrl(url: String, scheme: String): String? { + val prefix = "$scheme://" + val rest = url.trim() + .takeIf { it.startsWith(prefix, ignoreCase = true) } + ?.substring(prefix.length) + ?.takeIf { it.isNotBlank() && !it.startsWith("/") } + ?: return null + return "https://$rest" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index b1654a89..687820da 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull @@ -113,7 +114,7 @@ object MetaDetailsRepository { scope.launch { val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type) - val manifests = findMetaManifests(type = type, id = metaLookupId) + val manifests = findReadyMetaManifests(type = type, id = metaLookupId) if (manifests.isEmpty()) { val tmdbMeta = tryFetchTmdbFallbackMeta(type = type, id = id) @@ -197,7 +198,7 @@ object MetaDetailsRepository { cachedMetaByRequestKey[requestKey]?.let { return it.baseMeta } val metaLookupId = resolveMetaLookupId(itemId = id, itemType = type) - val manifests = findMetaManifests(type = type, id = metaLookupId) + val manifests = findReadyMetaManifests(type = type, id = metaLookupId) for (manifest in manifests) { val result = withTimeoutOrNull(FETCH_TIMEOUT_MS) { @@ -215,6 +216,7 @@ object MetaDetailsRepository { } private const val FETCH_TIMEOUT_MS = 5_000L + private const val METADATA_PROVIDER_READY_TIMEOUT_MS = 10_000L private const val TMDB_ENRICH_TIMEOUT_MS = 5_000L private const val MDBLIST_ENRICH_TIMEOUT_MS = 5_000L @@ -269,8 +271,27 @@ object MetaDetailsRepository { } } - private fun findMetaManifests(type: String, id: String): List = - AddonRepository.uiState.value.addons + private suspend fun findReadyMetaManifests(type: String, id: String): List { + AddonRepository.initialize() + + findMetaManifests(AddonRepository.uiState.value, type, id).takeIf { it.isNotEmpty() }?.let { return it } + + if (!AddonRepository.uiState.value.hasPendingEnabledAddonManifests()) { + return emptyList() + } + + val readyState = withTimeoutOrNull(METADATA_PROVIDER_READY_TIMEOUT_MS) { + AddonRepository.uiState.first { state -> + findMetaManifests(state, type, id).isNotEmpty() || + !state.hasPendingEnabledAddonManifests() + } + } ?: AddonRepository.uiState.value + + return findMetaManifests(readyState, type, id) + } + + private fun findMetaManifests(state: com.nuvio.app.features.addons.AddonsUiState, type: String, id: String): List = + state.addons .enabledAddons() .mapNotNull { it.manifest } .filter { manifest -> @@ -281,6 +302,9 @@ object MetaDetailsRepository { } } + private fun com.nuvio.app.features.addons.AddonsUiState.hasPendingEnabledAddonManifests(): Boolean = + addons.enabledAddons().any { addon -> addon.manifest == null && addon.isRefreshing } + private suspend fun resolveMetaLookupId(itemId: String, itemType: String): String { val tmdbId = itemId .takeIf { it.startsWith("tmdb:", ignoreCase = true) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt index 72a9bc50..8ce75e36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt @@ -129,7 +129,7 @@ object PlayerSubtitleCueParser { } private fun parseTtml(text: String): List = - Regex("""]*)>(.*?)

""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)) + Regex("""]*)>([\s\S]*?)

""", RegexOption.IGNORE_CASE) .findAll(text) .mapNotNull { match -> val attrs = match.groupValues[1] diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/deeplink/AppUrlBridgeTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/deeplink/AppUrlBridgeTest.kt new file mode 100644 index 00000000..47a406de --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/deeplink/AppUrlBridgeTest.kt @@ -0,0 +1,66 @@ +package com.nuvio.app.core.deeplink + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class AppUrlBridgeTest { + + @Test + fun `parses existing notification meta deeplink`() { + assertEquals( + AppDeepLink.Meta(type = "series", id = "tt0944947"), + parseAppDeepLink("nuvio://meta?type=series&id=tt0944947"), + ) + } + + @Test + fun `parses direct nuvio addon install deeplink`() { + assertEquals( + AppDeepLink.AddonInstall("https://free.nebulapro.xyz/sports/i/free/manifest.json"), + parseAppDeepLink("nuvio://free.nebulapro.xyz/sports/i/free/manifest.json"), + ) + } + + @Test + fun `parses stremio addon install deeplink`() { + assertEquals( + AppDeepLink.AddonInstall("https://free.nebulapro.xyz/sports/i/free/manifest.json"), + parseAppDeepLink("stremio://free.nebulapro.xyz/sports/i/free/manifest.json"), + ) + } + + @Test + fun `parses direct imdb detail deeplink`() { + assertEquals( + AppDeepLink.Meta(type = "series", id = "tt0944947"), + parseAppDeepLink("nuvio://series/tt0944947"), + ) + } + + @Test + fun `parses provider imdb detail deeplink`() { + assertEquals( + AppDeepLink.Meta(type = "series", id = "tt0944947"), + parseAppDeepLink("nuvio://imdb/series/tt0944947"), + ) + } + + @Test + fun `parses provider tmdb detail deeplink`() { + assertEquals( + AppDeepLink.Meta(type = "series", id = "tmdb:1399"), + parseAppDeepLink("nuvio://tmdb/tv/1399"), + ) + } + + @Test + fun `does not treat reserved auth link as addon install`() { + assertNull(parseAppDeepLink("nuvio://auth/trakt?code=abc")) + } + + @Test + fun `does not treat non-host stremio link as addon install`() { + assertNull(parseAppDeepLink("stremio://detail/series/tt0944947")) + } +} diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 8ad977fa..1a3c1602 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -14,6 +14,7 @@ CFBundleURLSchemes nuvio + stremio