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