diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt new file mode 100644 index 00000000..47b852fe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt @@ -0,0 +1,46 @@ +package com.nuvio.app.features.addons + +internal fun addonTransportBaseUrl(manifestUrl: String): String = + manifestUrl.substringBefore("?").removeSuffix("/manifest.json") + +internal fun buildAddonResourceUrl( + manifestUrl: String, + resource: String, + type: String, + id: String, + extraPathSegment: String? = null, +): String { + val encodedId = id.encodeAddonPathSegment() + val baseUrl = addonTransportBaseUrl(manifestUrl) + return if (extraPathSegment.isNullOrEmpty()) { + "$baseUrl/$resource/$type/$encodedId.json" + } else { + "$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json" + } +} + + +internal fun String.encodeAddonPathSegment(): String = + buildString { + encodeToByteArray().forEach { byte -> + val value = byte.toInt() and 0xFF + val char = value.toChar() + if ( + char in 'a'..'z' || + char in 'A'..'Z' || + char in '0'..'9' || + char == '-' || + char == '_' || + char == '.' || + char == '~' + ) { + append(char) + } else { + append('%') + append(ADDON_URL_HEX[value shr 4]) + append(ADDON_URL_HEX[value and 0x0F]) + } + } + } + +private const val ADDON_URL_HEX = "0123456789ABCDEF" \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt index d0cdd365..f2f763a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.catalog import com.nuvio.app.features.addons.AddonCatalog +import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.home.HomeCatalogParser import com.nuvio.app.features.home.MetaPreview @@ -122,21 +123,19 @@ internal fun buildCatalogUrl( search: String?, skip: Int?, ): String { - val baseUrl = manifestUrl - .substringBefore("?") - .removeSuffix("/manifest.json") - val extraParts = buildList { if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}") if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}") if (skip != null && skip > 0) add("skip=$skip") } - return if (extraParts.isEmpty()) { - "$baseUrl/catalog/$type/$catalogId.json" - } else { - "$baseUrl/catalog/$type/$catalogId/${extraParts.joinToString(separator = "&")}.json" - } + return buildAddonResourceUrl( + manifestUrl = manifestUrl, + resource = "catalog", + type = type, + id = catalogId, + extraPathSegment = extraParts.joinToString(separator = "&").ifBlank { null }, + ) } private fun String.encodeCatalogExtra(): String = 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 ad11531c..a5d32843 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 @@ -3,6 +3,7 @@ package com.nuvio.app.features.details import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListSettingsRepository @@ -217,10 +218,12 @@ object MetaDetailsRepository { id: String, includeMdbList: Boolean, ): MetaDetails? { - val baseUrl = manifest.transportUrl - .substringBefore("?") - .removeSuffix("/manifest.json") - val url = "$baseUrl/meta/$type/$id.json" + val url = buildAddonResourceUrl( + manifestUrl = manifest.transportUrl, + resource = "meta", + type = type, + id = id, + ) return try { TmdbSettingsRepository.ensureLoaded() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index b64becba..e1a8e29f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.player import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository @@ -215,11 +216,12 @@ object PlayerStreamsRepository { val job = scope.launch { val addonJobs = streamAddons.map { addon -> async { - val encodedId = videoId.replace("%", "%25").replace(" ", "%20") - val baseUrl = addon.manifest.transportUrl - .substringBefore("?") - .removeSuffix("/manifest.json") - val url = "$baseUrl/stream/$type/$encodedId.json" + val url = buildAddonResourceUrl( + manifestUrl = addon.manifest.transportUrl, + resource = "stream", + type = type, + id = videoId, + ) val displayName = addon.addonName runCatching { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index 7164d596..82ed1dcb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.player import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -49,8 +50,12 @@ object SubtitleRepository { subtitleResource.idPrefixes.any { videoId.startsWith(it) } if (!prefixMatch) continue - val baseUrl = manifest.transportUrl.substringBeforeLast("/manifest.json") - val subtitleUrl = "$baseUrl/subtitles/$type/$videoId.json" + val subtitleUrl = buildAddonResourceUrl( + manifestUrl = manifest.transportUrl, + resource = "subtitles", + type = type, + id = videoId, + ) try { val response = withContext(Dispatchers.Default) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index cf83d7ea..61e04a2b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.streams import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.player.PlayerSettingsRepository @@ -237,11 +238,12 @@ object StreamsRepository { streamAddons.forEach { addon -> launch { - val encodedId = videoId.encodeForPath() - val baseUrl = addon.manifest.transportUrl - .substringBefore("?") - .removeSuffix("/manifest.json") - val url = "$baseUrl/stream/$type/$encodedId.json" + val url = buildAddonResourceUrl( + manifestUrl = addon.manifest.transportUrl, + resource = "stream", + type = type, + id = videoId, + ) log.d { "Fetching streams from: $url" } val displayName = addon.addonName @@ -420,10 +422,6 @@ object StreamsRepository { activeRequestKey = null _uiState.value = StreamsUiState() } - - // Encode id segment so colons and slashes don't break URL path parsing on addons - private fun String.encodeForPath(): String = - replace("%", "%25").replace(" ", "%20") } private data class InstalledStreamAddonTarget(