diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index eccfad41..6ec0d863 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -145,6 +145,8 @@ kotlin { implementation(libs.supabase.postgrest) implementation(libs.supabase.auth) implementation(libs.supabase.functions) + implementation(libs.quickjs.kt) + implementation(libs.ksoup) } iosMain.dependencies { implementation(libs.ktor.client.darwin) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 5ce860f1..08816b20 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -13,6 +13,7 @@ import com.nuvio.app.features.library.LibraryStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage import com.nuvio.app.features.mdblist.MdbListSettingsStorage import com.nuvio.app.features.player.PlayerSettingsStorage +import com.nuvio.app.features.plugins.PluginStorage import com.nuvio.app.features.profiles.ProfileStorage import com.nuvio.app.features.details.SeasonViewModeStorage import com.nuvio.app.features.search.SearchHistoryStorage @@ -52,6 +53,7 @@ class MainActivity : ComponentActivity() { ContinueWatchingPreferencesStorage.initialize(applicationContext) WatchProgressStorage.initialize(applicationContext) StreamLinkCacheStorage.initialize(applicationContext) + PluginStorage.initialize(applicationContext) PlatformLocalAccountDataCleaner.initialize(applicationContext) forwardTraktAuthCallback(intent) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index aa83c19b..0d6f9b70 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -16,6 +16,7 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_stream_link_cache", "nuvio_continue_watching_preferences", "nuvio_watch_progress", + "nuvio_plugins", ) private var appContext: Context? = null diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index ed19647c..0f90e8bc 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -9,10 +9,13 @@ import io.ktor.client.request.accept import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post +import io.ktor.client.request.request import io.ktor.client.request.setBody +import io.ktor.client.request.url import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod import io.ktor.http.isSuccess actual object AddonStorage { @@ -131,3 +134,32 @@ actual suspend fun httpPostJsonWithHeaders( } payload } + +actual suspend fun httpRequestRaw( + method: String, + url: String, + headers: Map, + body: String, +): RawHttpResponse = + addonHttpClient + .request { + url(url) + this.method = HttpMethod.parse(method.uppercase()) + headers.forEach { (key, value) -> + header(key, value) + } + if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) { + setBody(body) + } + } + .let { response -> + RawHttpResponse( + status = response.status.value, + statusText = response.status.description, + url = response.call.request.url.toString(), + body = response.bodyAsText(), + headers = response.headers.entries().associate { (name, values) -> + name.lowercase() to values.joinToString(",") + }, + ) + } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt new file mode 100644 index 00000000..f99c6bd4 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.features.plugins + +import android.content.Context +import android.content.SharedPreferences + +internal actual object PluginStorage { + private const val preferencesName = "nuvio_plugins" + private const val pluginsStateKey = "plugins_state" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadState(profileId: Int): String? = + preferences?.getString("${pluginsStateKey}_$profileId", null) + + actual fun saveState(profileId: Int, payload: String) { + preferences + ?.edit() + ?.putString("${pluginsStateKey}_$profileId", payload) + ?.apply() + } +} + +internal actual fun currentPluginPlatform(): String = "android" + +internal actual fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 523ffdec..9c6c6cd0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -101,6 +101,7 @@ import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen import com.nuvio.app.features.settings.AddonsSettingsScreen +import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.streams.StreamContext @@ -136,6 +137,9 @@ object ContinueWatchingSettingsRoute @Serializable object AddonsSettingsRoute +@Serializable +object PluginsSettingsRoute + @Serializable object AccountSettingsRoute @@ -506,6 +510,7 @@ private fun MainAppContent( onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, + onPluginsSettingsClick = { navController.navigate(PluginsSettingsRoute) }, onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, onInitialHomeContentRendered = { initialHomeReady = true }, ) @@ -816,6 +821,11 @@ private fun MainAppContent( onBack = { navController.popBackStack() }, ) } + composable { + PluginsSettingsScreen( + onBack = { navController.popBackStack() }, + ) + } composable { AccountSettingsScreen( onBack = { navController.popBackStack() }, @@ -883,6 +893,7 @@ private fun AppTabHost( onHomescreenSettingsClick: () -> Unit = {}, onContinueWatchingSettingsClick: () -> Unit = {}, onAddonsSettingsClick: () -> Unit = {}, + onPluginsSettingsClick: () -> Unit = {}, onAccountSettingsClick: () -> Unit = {}, onInitialHomeContentRendered: () -> Unit = {}, ) { @@ -927,6 +938,7 @@ private fun AppTabHost( onHomescreenClick = onHomescreenSettingsClick, onContinueWatchingClick = onContinueWatchingSettingsClick, onAddonsClick = onAddonsSettingsClick, + onPluginsClick = onPluginsSettingsClick, onAccountClick = onAccountSettingsClick, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 5ce306e4..8e97767b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -8,6 +8,7 @@ import com.nuvio.app.features.home.HomeRepository import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.player.SubtitleRepository import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.search.SearchRepository @@ -25,6 +26,7 @@ internal object LocalAccountDataCleaner { ProfileRepository.clearInMemory() AddonRepository.clearLocalState() + PluginRepository.clearLocalState() HomeRepository.clear() HomeCatalogSettingsRepository.clearLocalState() LibraryRepository.clearLocalState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 48326b84..8d135d00 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.watched.WatchedRepository @@ -30,6 +31,11 @@ object SyncManager { .onSuccess { log.i { "pullAllForProfile — addons pull completed" } } .onFailure { log.e(it) { "Addon pull failed" } } + log.i { "pullAllForProfile — pulling plugins (await)..." } + runCatching { PluginRepository.pullFromServer(profileId) } + .onSuccess { log.i { "pullAllForProfile — plugins pull completed" } } + .onFailure { log.e(it) { "Plugin pull failed" } } + log.i { "pullAllForProfile — launching remaining pulls in parallel" } launch { runCatching { LibraryRepository.pullFromServer(profileId) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index 273b7a94..90ddf249 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -5,6 +5,14 @@ internal expect object AddonStorage { fun saveInstalledAddonUrls(profileId: Int, urls: List) } +data class RawHttpResponse( + val status: Int, + val statusText: String, + val url: String, + val body: String, + val headers: Map, +) + expect suspend fun httpGetText(url: String): String expect suspend fun httpPostJson(url: String, body: String): String @@ -19,3 +27,10 @@ expect suspend fun httpPostJsonWithHeaders( body: String, headers: Map, ): String + +expect suspend fun httpRequestRaw( + method: String, + url: String, + headers: Map, + body: String, +): RawHttpResponse diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 40d391f9..d85fa11b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -520,7 +520,12 @@ fun PlayerScreen( fun openSourcesPanel() { val type = contentType ?: parentMetaType val vid = activeVideoId ?: return - PlayerStreamsRepository.loadSources(type, vid) + PlayerStreamsRepository.loadSources( + type = type, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + ) showSourcesPanel = true showEpisodesPanel = false controlsVisible = false @@ -961,7 +966,13 @@ fun PlayerScreen( onReload = { val type = contentType ?: parentMetaType val vid = activeVideoId ?: return@PlayerSourcesPanel - PlayerStreamsRepository.loadSources(type, vid, forceRefresh = true) + PlayerStreamsRepository.loadSources( + type = type, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + forceRefresh = true, + ) }, onDismiss = { showSourcesPanel = false @@ -982,7 +993,12 @@ fun PlayerScreen( onSeasonSelected = { /* season tab change handled internally */ }, onEpisodeSelected = { episode -> val type = contentType ?: parentMetaType - PlayerStreamsRepository.loadEpisodeStreams(type, episode.id) + PlayerStreamsRepository.loadEpisodeStreams( + type = type, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + ) episodeStreamsPanelState = EpisodeStreamsPanelState( showStreams = true, selectedEpisode = episode, @@ -999,7 +1015,13 @@ fun PlayerScreen( onReloadEpisodeStreams = { val episode = episodeStreamsPanelState.selectedEpisode ?: return@PlayerEpisodesPanel val type = contentType ?: parentMetaType - PlayerStreamsRepository.loadEpisodeStreams(type, episode.id, forceRefresh = true) + PlayerStreamsRepository.loadEpisodeStreams( + type = type, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + forceRefresh = true, + ) }, onDismiss = { showEpisodesPanel = false 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 e519f9ed..cbb77552 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 @@ -4,7 +4,11 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.plugins.PluginRepository +import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.PluginScraper import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamParser import com.nuvio.app.features.streams.StreamsUiState import kotlinx.coroutines.CoroutineScope @@ -38,10 +42,18 @@ object PlayerStreamsRepository { private var episodeStreamsJob: Job? = null private var episodeStreamsRequestKey: String? = null - fun loadSources(type: String, videoId: String, forceRefresh: Boolean = false) { + fun loadSources( + type: String, + videoId: String, + season: Int? = null, + episode: Int? = null, + forceRefresh: Boolean = false, + ) { fetchStreams( type = type, videoId = videoId, + season = season, + episode = episode, forceRefresh = forceRefresh, stateFlow = _sourceState, requestKeyHolder = { sourceRequestKey }, @@ -51,10 +63,18 @@ object PlayerStreamsRepository { ) } - fun loadEpisodeStreams(type: String, videoId: String, forceRefresh: Boolean = false) { + fun loadEpisodeStreams( + type: String, + videoId: String, + season: Int? = null, + episode: Int? = null, + forceRefresh: Boolean = false, + ) { fetchStreams( type = type, videoId = videoId, + season = season, + episode = episode, forceRefresh = forceRefresh, stateFlow = _episodeStreamsState, requestKeyHolder = { episodeStreamsRequestKey }, @@ -88,6 +108,8 @@ object PlayerStreamsRepository { private fun fetchStreams( type: String, videoId: String, + season: Int?, + episode: Int?, forceRefresh: Boolean, stateFlow: MutableStateFlow, requestKeyHolder: () -> String?, @@ -95,7 +117,7 @@ object PlayerStreamsRepository { jobHolder: () -> Job?, setJob: (Job) -> Unit, ) { - val requestKey = "$type::$videoId" + val requestKey = "$type::$videoId::$season::$episode" val current = stateFlow.value if ( !forceRefresh && @@ -127,7 +149,10 @@ object PlayerStreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons - if (installedAddons.isEmpty()) { + PluginRepository.initialize() + val pluginScrapers = PluginRepository.getEnabledScrapersForType(type) + + if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, @@ -146,7 +171,7 @@ object PlayerStreamsRepository { } } - if (streamAddons.isEmpty()) { + if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, @@ -161,15 +186,22 @@ object PlayerStreamsRepository { streams = emptyList(), isLoading = true, ) + } + pluginScrapers.map { scraper -> + AddonStreamGroup( + addonName = scraper.name, + addonId = "plugin:${scraper.id}", + streams = emptyList(), + isLoading = true, + ) } stateFlow.value = StreamsUiState( groups = initialGroups, - activeAddonIds = streamAddons.map { it.id }.toSet(), + activeAddonIds = initialGroups.map { it.addonId }.toSet(), isAnyLoading = true, ) val job = scope.launch { - val jobs = streamAddons.map { manifest -> + val addonJobs = streamAddons.map { manifest -> async { val encodedId = videoId.replace("%", "%25").replace(" ", "%20") val baseUrl = manifest.transportUrl @@ -191,6 +223,39 @@ object PlayerStreamsRepository { ) } } + + val pluginJobs = pluginScrapers.map { scraper -> + async { + PluginRepository.executeScraper( + scraper = scraper, + tmdbId = videoId.toPluginTmdbId(), + mediaType = type, + season = season, + episode = episode, + ).fold( + onSuccess = { results -> + AddonStreamGroup( + addonName = scraper.name, + addonId = "plugin:${scraper.id}", + streams = results.map { it.toStreamItem(scraper) }, + isLoading = false, + ) + }, + onFailure = { err -> + log.w(err) { "Plugin scraper failed: ${scraper.name}" } + AddonStreamGroup( + addonName = scraper.name, + addonId = "plugin:${scraper.id}", + streams = emptyList(), + isLoading = false, + error = err.message, + ) + }, + ) + } + } + + val jobs = addonJobs + pluginJobs jobs.forEach { deferred -> val result = deferred.await() stateFlow.update { current -> @@ -213,3 +278,27 @@ object PlayerStreamsRepository { setJob(job) } } + +private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem { + val subtitleParts = listOfNotNull( + quality?.takeIf { it.isNotBlank() }, + size?.takeIf { it.isNotBlank() }, + language?.takeIf { it.isNotBlank() }, + ) + return StreamItem( + name = name ?: title, + description = subtitleParts.joinToString(" • ").ifBlank { null }, + url = url, + infoHash = infoHash, + addonName = scraper.name, + addonId = "plugin:${scraper.id}", + ) +} + +private fun String.toPluginTmdbId(): String { + return when { + startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this } + startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this } + else -> this + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt new file mode 100644 index 00000000..80117695 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.plugins + +import kotlinx.serialization.json.Json + +internal object PluginManifestParser { + private val json = Json { + ignoreUnknownKeys = true + } + + fun parse(payload: String): PluginManifest { + val manifest = json.decodeFromString(payload) + require(manifest.name.isNotBlank()) { "Manifest name is missing." } + require(manifest.version.isNotBlank()) { "Manifest version is missing." } + require(manifest.scrapers.isNotEmpty()) { "Manifest has no scrapers." } + return manifest + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt new file mode 100644 index 00000000..39255952 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -0,0 +1,130 @@ +package com.nuvio.app.features.plugins + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PluginManifest( + val name: String, + val version: String, + val description: String? = null, + val author: String? = null, + val scrapers: List = emptyList(), +) + +@Serializable +data class PluginManifestScraper( + val id: String, + val name: String, + val description: String? = null, + val version: String, + val filename: String, + @SerialName("supportedTypes") val supportedTypes: List = listOf("movie", "tv"), + val enabled: Boolean = true, + val logo: String? = null, + @SerialName("contentLanguage") val contentLanguage: List? = null, + @SerialName("supportedPlatforms") val supportedPlatforms: List? = null, + @SerialName("disabledPlatforms") val disabledPlatforms: List? = null, + val formats: List? = null, + @SerialName("supportedFormats") val supportedFormats: List? = null, + @SerialName("supportsExternalPlayer") val supportsExternalPlayer: Boolean? = null, + val limited: Boolean? = null, +) + +data class PluginRepositoryItem( + val manifestUrl: String, + val name: String, + val description: String? = null, + val version: String? = null, + val scraperCount: Int = 0, + val lastUpdated: Long = 0L, + val isRefreshing: Boolean = false, + val errorMessage: String? = null, +) + +data class PluginScraper( + val id: String, + val repositoryUrl: String, + val name: String, + val description: String, + val version: String, + val filename: String, + val supportedTypes: List, + val enabled: Boolean, + val manifestEnabled: Boolean, + val logo: String? = null, + val contentLanguage: List = emptyList(), + val formats: List? = null, + val code: String, +) { + fun supportsType(type: String): Boolean { + val normalizedType = normalizePluginType(type) + return supportedTypes.map { normalizePluginType(it) }.contains(normalizedType) + } +} + +data class PluginRuntimeResult( + val title: String, + val name: String? = null, + val url: String, + val quality: String? = null, + val size: String? = null, + val language: String? = null, + val provider: String? = null, + val type: String? = null, + val seeders: Int? = null, + val peers: Int? = null, + val infoHash: String? = null, + val headers: Map? = null, +) + +data class PluginsUiState( + val pluginsEnabled: Boolean = true, + val repositories: List = emptyList(), + val scrapers: List = emptyList(), +) + +sealed interface AddPluginRepositoryResult { + data class Success(val repository: PluginRepositoryItem) : AddPluginRepositoryResult + data class Error(val message: String) : AddPluginRepositoryResult +} + +@Serializable +internal data class StoredPluginsState( + val pluginsEnabled: Boolean = true, + val repositories: List = emptyList(), + val scrapers: List = emptyList(), +) + +@Serializable +internal data class StoredPluginRepository( + val manifestUrl: String, + val name: String, + val description: String? = null, + val version: String? = null, + val scraperCount: Int = 0, + val lastUpdated: Long = 0L, +) + +@Serializable +internal data class StoredPluginScraper( + val id: String, + val repositoryUrl: String, + val name: String, + val description: String, + val version: String, + val filename: String, + val supportedTypes: List, + val enabled: Boolean, + val manifestEnabled: Boolean, + val logo: String? = null, + val contentLanguage: List = emptyList(), + val formats: List? = null, + val code: String, +) + +internal fun normalizePluginType(value: String): String = + when (value.lowercase()) { + "series", "show", "other" -> "tv" + else -> value.lowercase() + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt new file mode 100644 index 00000000..2b6c3fa1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt @@ -0,0 +1,10 @@ +package com.nuvio.app.features.plugins + +internal expect object PluginStorage { + fun loadState(profileId: Int): String? + fun saveState(profileId: Int, payload: String) +} + +internal expect fun currentPluginPlatform(): String + +internal expect fun currentEpochMillis(): Long diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt new file mode 100644 index 00000000..3c45853b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -0,0 +1,531 @@ +package com.nuvio.app.features.plugins + +import co.touchlab.kermit.Logger +import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.profiles.ProfileRepository +import io.github.jan.supabase.postgrest.postgrest +import io.github.jan.supabase.postgrest.query.Order +import io.github.jan.supabase.postgrest.rpc +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.put + +@Serializable +private data class PluginRow( + val url: String, + val name: String? = null, + val enabled: Boolean = true, + @SerialName("sort_order") val sortOrder: Int = 0, +) + +@Serializable +private data class PluginPushItem( + val url: String, + val name: String = "", + val enabled: Boolean = true, + @SerialName("sort_order") val sortOrder: Int = 0, +) + +object PluginRepository { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val log = Logger.withTag("PluginRepository") + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + private val _uiState = MutableStateFlow(PluginsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var initialized = false + private var pulledFromServer = false + private var currentProfileId = 1 + private val activeRefreshJobs = mutableMapOf() + + fun initialize() { + if (initialized) return + currentProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) + val stored = loadStoredState(currentProfileId) + + _uiState.value = PluginsUiState( + pluginsEnabled = stored?.pluginsEnabled ?: true, + repositories = stored?.repositories + ?.map { + PluginRepositoryItem( + manifestUrl = it.manifestUrl, + name = it.name, + description = it.description, + version = it.version, + scraperCount = it.scraperCount, + lastUpdated = it.lastUpdated, + isRefreshing = false, + errorMessage = null, + ) + } + ?: emptyList(), + scrapers = stored?.scrapers + ?.map { + PluginScraper( + id = it.id, + repositoryUrl = it.repositoryUrl, + name = it.name, + description = it.description, + version = it.version, + filename = it.filename, + supportedTypes = it.supportedTypes, + enabled = it.enabled, + manifestEnabled = it.manifestEnabled, + logo = it.logo, + contentLanguage = it.contentLanguage, + formats = it.formats, + code = it.code, + ) + } + ?: emptyList(), + ) + + initialized = true + + _uiState.value.repositories.forEach { repo -> + refreshRepository(repo.manifestUrl) + } + } + + fun onProfileChanged(profileId: Int) { + val effectiveProfileId = resolveEffectiveProfileId(profileId) + if (effectiveProfileId == currentProfileId && initialized) return + + cancelActiveRefreshes() + currentProfileId = effectiveProfileId + initialized = false + pulledFromServer = false + _uiState.value = PluginsUiState() + } + + fun clearLocalState() { + cancelActiveRefreshes() + currentProfileId = 1 + initialized = false + pulledFromServer = false + _uiState.value = PluginsUiState() + } + + suspend fun pullFromServer(profileId: Int) { + currentProfileId = resolveEffectiveProfileId(profileId) + runCatching { + val rows = SupabaseProvider.client.postgrest + .from("plugins") + .select { + filter { eq("profile_id", currentProfileId) } + order("sort_order", Order.ASCENDING) + } + .decodeList() + + val urls = dedupeManifestUrls(rows.map { it.url }) + if (urls.isEmpty() && !pulledFromServer) { + val localUrls = _uiState.value.repositories.map { it.manifestUrl } + if (localUrls.isNotEmpty()) { + initialize() + pulledFromServer = true + pushToServer() + return + } + } + + val existingReposByUrl = _uiState.value.repositories.associateBy { it.manifestUrl } + val nextRepos = urls.map { url -> + existingReposByUrl[url]?.copy(isRefreshing = true, errorMessage = null) + ?: PluginRepositoryItem( + manifestUrl = url, + name = url.substringBefore("?").substringAfterLast('/'), + isRefreshing = true, + ) + } + val nextScrapers = _uiState.value.scrapers.filter { scraper -> + urls.contains(scraper.repositoryUrl) + } + + _uiState.value = PluginsUiState( + pluginsEnabled = _uiState.value.pluginsEnabled, + repositories = nextRepos, + scrapers = nextScrapers, + ) + persist() + + urls.forEach { url -> + refreshRepository(url, pushAfterRefresh = false) + } + + pulledFromServer = true + initialized = true + }.onFailure { error -> + log.e(error) { "pullFromServer failed" } + } + } + + suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult { + initialize() + val manifestUrl = try { + normalizeManifestUrl(rawUrl) + } catch (error: IllegalArgumentException) { + return AddPluginRepositoryResult.Error(error.message ?: "Enter a valid plugin URL") + } + + if (_uiState.value.repositories.any { it.manifestUrl == manifestUrl }) { + return AddPluginRepositoryResult.Error("That plugin repository is already installed.") + } + + return try { + val previousById = _uiState.value.scrapers.associateBy { it.id } + val (repo, scrapers) = fetchRepositoryData( + manifestUrl = manifestUrl, + previousScrapers = previousById, + ) + _uiState.update { state -> + state.copy( + repositories = state.repositories + repo, + scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl } + scrapers, + ) + } + persist() + pushToServer() + AddPluginRepositoryResult.Success(repo) + } catch (error: Throwable) { + AddPluginRepositoryResult.Error(error.message ?: "Unable to install plugin repository") + } + } + + fun removeRepository(manifestUrl: String) { + initialize() + _uiState.update { state -> + state.copy( + repositories = state.repositories.filterNot { it.manifestUrl == manifestUrl }, + scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl }, + ) + } + persist() + pushToServer() + } + + fun refreshAll() { + initialize() + _uiState.value.repositories.forEach { repo -> + refreshRepository(repo.manifestUrl) + } + } + + fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean = false) { + initialize() + val existingJob = activeRefreshJobs[manifestUrl] + if (existingJob?.isActive == true) return + + markRefreshing(manifestUrl) + var refreshJob: Job? = null + refreshJob = scope.launch { + try { + val result = runCatching { + val previous = _uiState.value.scrapers.associateBy { it.id } + fetchRepositoryData(manifestUrl, previous) + } + + _uiState.update { state -> + result.fold( + onSuccess = { (repo, scrapers) -> + val updatedRepos = state.repositories.map { existing -> + if (existing.manifestUrl == manifestUrl) repo else existing + } + state.copy( + repositories = updatedRepos, + scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl } + scrapers, + ) + }, + onFailure = { error -> + state.copy( + repositories = state.repositories.map { existing -> + if (existing.manifestUrl == manifestUrl) { + existing.copy( + isRefreshing = false, + errorMessage = error.message ?: "Unable to refresh repository", + ) + } else { + existing + } + }, + ) + }, + ) + } + persist() + if (pushAfterRefresh) { + pushToServer() + } + } finally { + if (activeRefreshJobs[manifestUrl] === refreshJob) { + activeRefreshJobs.remove(manifestUrl) + } + } + } + activeRefreshJobs[manifestUrl] = refreshJob + } + + fun toggleScraper(scraperId: String, enabled: Boolean) { + initialize() + _uiState.update { state -> + state.copy( + scrapers = state.scrapers.map { scraper -> + if (scraper.id == scraperId) { + scraper.copy(enabled = if (scraper.manifestEnabled) enabled else false) + } else { + scraper + } + }, + ) + } + persist() + } + + fun setPluginsEnabled(enabled: Boolean) { + initialize() + _uiState.update { it.copy(pluginsEnabled = enabled) } + persist() + } + + fun getEnabledScrapersForType(type: String): List { + initialize() + if (!_uiState.value.pluginsEnabled) return emptyList() + return _uiState.value.scrapers.filter { scraper -> + scraper.enabled && scraper.supportsType(type) + } + } + + suspend fun testScraper(scraperId: String): Result> { + initialize() + val scraper = _uiState.value.scrapers.find { it.id == scraperId } + ?: return Result.failure(IllegalArgumentException("Scraper not found")) + + val mediaType = if (scraper.supportsType("movie")) "movie" else "tv" + val season = if (mediaType == "tv") 1 else null + val episode = if (mediaType == "tv") 1 else null + return executeScraper( + scraper = scraper, + tmdbId = "603", + mediaType = mediaType, + season = season, + episode = episode, + ) + } + + suspend fun executeScraper( + scraper: PluginScraper, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + ): Result> { + return runCatching { + PluginRuntime.executePlugin( + code = scraper.code, + tmdbId = tmdbId, + mediaType = normalizePluginType(mediaType), + season = season, + episode = episode, + scraperId = scraper.id, + scraperSettings = emptyMap(), + ) + } + } + + private suspend fun fetchRepositoryData( + manifestUrl: String, + previousScrapers: Map, + ): Pair> = withContext(Dispatchers.Default) { + val payload = httpGetText(manifestUrl) + val manifest = PluginManifestParser.parse(payload) + val baseUrl = manifestUrl.substringBefore("?").removeSuffix("/manifest.json") + + val scrapers = manifest.scrapers + .filter { scraper -> scraper.isSupportedOnCurrentPlatform() } + .mapNotNull { info -> + val codeUrl = if (info.filename.startsWith("http://") || info.filename.startsWith("https://")) { + info.filename + } else { + "$baseUrl/${info.filename.trimStart('/')}" + } + runCatching { + val code = httpGetText(codeUrl) + val scraperId = "${manifestUrl.lowercase()}:${info.id}" + val previous = previousScrapers[scraperId] + val enabled = when { + !info.enabled -> false + previous != null -> previous.enabled + else -> info.enabled + } + + PluginScraper( + id = scraperId, + repositoryUrl = manifestUrl, + name = info.name, + description = info.description.orEmpty(), + version = info.version, + filename = info.filename, + supportedTypes = info.supportedTypes, + enabled = enabled, + manifestEnabled = info.enabled, + logo = info.logo, + contentLanguage = info.contentLanguage ?: emptyList(), + formats = info.formats ?: info.supportedFormats, + code = code, + ) + }.getOrNull() + } + + val repo = PluginRepositoryItem( + manifestUrl = manifestUrl, + name = manifest.name, + description = manifest.description, + version = manifest.version, + scraperCount = scrapers.size, + lastUpdated = currentEpochMillis(), + isRefreshing = false, + errorMessage = null, + ) + repo to scrapers + } + + private fun PluginManifestScraper.isSupportedOnCurrentPlatform(): Boolean { + val platform = currentPluginPlatform().lowercase() + val supported = supportedPlatforms?.map { it.lowercase() }?.toSet().orEmpty() + val disabled = disabledPlatforms?.map { it.lowercase() }?.toSet().orEmpty() + if (supported.isNotEmpty() && platform !in supported) return false + if (platform in disabled) return false + return true + } + + private fun markRefreshing(manifestUrl: String) { + _uiState.update { state -> + state.copy( + repositories = state.repositories.map { repo -> + if (repo.manifestUrl == manifestUrl) { + repo.copy(isRefreshing = true, errorMessage = null) + } else { + repo + } + }, + ) + } + } + + private fun pushToServer() { + scope.launch { + runCatching { + val repos = _uiState.value.repositories.mapIndexed { index, repo -> + PluginPushItem( + url = repo.manifestUrl, + name = repo.name, + enabled = true, + sortOrder = index, + ) + } + + val params = buildJsonObject { + put("p_profile_id", currentProfileId) + put("p_plugins", json.encodeToJsonElement(repos)) + } + SupabaseProvider.client.postgrest.rpc("sync_push_plugins", params) + }.onFailure { error -> + log.e(error) { "pushToServer failed" } + } + } + } + + private fun persist() { + val state = _uiState.value + val payload = StoredPluginsState( + pluginsEnabled = state.pluginsEnabled, + repositories = state.repositories.map { repo -> + StoredPluginRepository( + manifestUrl = repo.manifestUrl, + name = repo.name, + description = repo.description, + version = repo.version, + scraperCount = repo.scraperCount, + lastUpdated = repo.lastUpdated, + ) + }, + scrapers = state.scrapers.map { scraper -> + StoredPluginScraper( + id = scraper.id, + repositoryUrl = scraper.repositoryUrl, + name = scraper.name, + description = scraper.description, + version = scraper.version, + filename = scraper.filename, + supportedTypes = scraper.supportedTypes, + enabled = scraper.enabled, + manifestEnabled = scraper.manifestEnabled, + logo = scraper.logo, + contentLanguage = scraper.contentLanguage, + formats = scraper.formats, + code = scraper.code, + ) + }, + ) + PluginStorage.saveState(currentProfileId, json.encodeToString(payload)) + } + + private fun loadStoredState(profileId: Int): StoredPluginsState? { + val raw = PluginStorage.loadState(profileId)?.trim().orEmpty() + if (raw.isBlank()) return null + return runCatching { + json.decodeFromString(raw) + }.getOrNull() + } + + private fun cancelActiveRefreshes() { + activeRefreshJobs.values.forEach(Job::cancel) + activeRefreshJobs.clear() + } + + private fun dedupeManifestUrls(urls: List): List = + urls.map(::ensureManifestSuffix).distinct() + + private fun ensureManifestSuffix(url: String): String { + val path = url.substringBefore("?").trimEnd('/') + val query = url.substringAfter("?", "") + val withSuffix = if (path.endsWith("/manifest.json")) path else "$path/manifest.json" + return if (query.isEmpty()) withSuffix else "$withSuffix?$query" + } + + private fun normalizeManifestUrl(rawUrl: String): String { + val trimmed = rawUrl.trim() + require(trimmed.isNotEmpty()) { "Enter a plugin repository URL." } + + val normalizedScheme = when { + trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed + else -> "https://$trimmed" + } + + val withoutFragment = normalizedScheme.substringBefore("#") + val query = withoutFragment.substringAfter("?", "") + val path = withoutFragment.substringBefore("?").trimEnd('/') + val manifestPath = if (path.endsWith("/manifest.json")) path else "$path/manifest.json" + return if (query.isEmpty()) manifestPath else "$manifestPath?$query" + } + + private fun resolveEffectiveProfileId(profileId: Int): Int { + val active = ProfileRepository.state.value.activeProfile + return if (active != null && !active.id.isBlank() && active.usesPrimaryPlugins) 1 else profileId + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt new file mode 100644 index 00000000..97ea1eff --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -0,0 +1,796 @@ +package com.nuvio.app.features.plugins + +import co.touchlab.kermit.Logger +import com.dokar.quickjs.binding.define +import com.dokar.quickjs.binding.function +import com.dokar.quickjs.quickJs +import com.fleeksoft.ksoup.Ksoup +import com.fleeksoft.ksoup.nodes.Document +import com.fleeksoft.ksoup.nodes.Element +import com.fleeksoft.ksoup.select.Elements +import com.nuvio.app.features.addons.httpRequestRaw +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonPrimitive +import kotlin.random.Random + +private const val PLUGIN_TIMEOUT_MS = 60_000L +private const val MAX_FETCH_BODY_CHARS = 256 * 1024 +private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024 +private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]" + +internal object PluginRuntime { + private val log = Logger.withTag("PluginRuntime") + private val json = Json { + ignoreUnknownKeys = true + } + + private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""") + + suspend fun executePlugin( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map = emptyMap(), + ): List = withContext(Dispatchers.Default) { + withTimeout(PLUGIN_TIMEOUT_MS) { + executePluginInternal( + code = code, + tmdbId = tmdbId, + mediaType = mediaType, + season = season, + episode = episode, + scraperId = scraperId, + scraperSettings = scraperSettings, + ) + } + } + + private suspend fun executePluginInternal( + code: String, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + scraperId: String, + scraperSettings: Map, + ): List { + val documentCache = mutableMapOf() + val elementCache = mutableMapOf() + var idCounter = 0 + var resultJson = "[]" + + try { + quickJs(Dispatchers.Default) { + define("console") { + function("log") { args -> + log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("error") { args -> + log.e { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("warn") { args -> + log.w { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("info") { args -> + log.i { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + function("debug") { args -> + log.d { "Plugin:$scraperId ${args.joinToString(" ") { it?.toString() ?: "null" }}" } + null + } + } + + function("__native_fetch") { args -> + val url = args.getOrNull(0)?.toString() ?: "" + val method = args.getOrNull(1)?.toString() ?: "GET" + val headersJson = args.getOrNull(2)?.toString() ?: "{}" + val body = args.getOrNull(3)?.toString() ?: "" + try { + performNativeFetch(url, method, headersJson, body) + } catch (t: Throwable) { + log.e(t) { "Fetch bridge error for $method $url" } + JsonObject( + mapOf( + "ok" to JsonPrimitive(false), + "status" to JsonPrimitive(0), + "statusText" to JsonPrimitive(t.message ?: "Fetch failed"), + "url" to JsonPrimitive(url), + "body" to JsonPrimitive(""), + "headers" to JsonObject(emptyMap()), + ), + ).toString() + } + } + + function("__parse_url") { args -> + parseUrl(args.getOrNull(0)?.toString() ?: "") + } + + function("__cheerio_load") { args -> + val html = args.getOrNull(0)?.toString() ?: "" + val docId = "doc_${idCounter++}_${Random.nextInt(0, Int.MAX_VALUE)}" + documentCache[docId] = Ksoup.parse(html) + docId + } + + function("__cheerio_select") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + var selector = args.getOrNull(1)?.toString() ?: "" + val doc = documentCache[docId] ?: return@function "[]" + try { + selector = selector.replace(containsRegex, ":contains($1)") + val elements = if (selector.isEmpty()) Elements() else doc.select(selector) + val ids = elements.mapIndexed { index, el -> + val id = "$docId:$index:${el.hashCode()}" + elementCache[id] = el + id + } + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (_: Exception) { + "[]" + } + } + + function("__cheerio_find") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + var selector = args.getOrNull(2)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "[]" + try { + selector = selector.replace(containsRegex, ":contains($1)") + val elements = element.select(selector) + val ids = elements.mapIndexed { index, el -> + val id = "$docId:find:$index:${el.hashCode()}" + elementCache[id] = el + id + } + "[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]" + } catch (_: Exception) { + "[]" + } + } + + function("__cheerio_text") { args -> + val elementIds = args.getOrNull(1)?.toString() ?: "" + elementIds.split(",") + .filter { it.isNotEmpty() } + .mapNotNull { elementCache[it]?.text() } + .joinToString(" ") + } + + function("__cheerio_html") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + if (elementId.isEmpty()) { + documentCache[docId]?.html() ?: "" + } else { + elementCache[elementId]?.html() ?: "" + } + } + + function("__cheerio_inner_html") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + elementCache[elementId]?.html() ?: "" + } + + function("__cheerio_attr") { args -> + val elementId = args.getOrNull(1)?.toString() ?: "" + val attrName = args.getOrNull(2)?.toString() ?: "" + val value = elementCache[elementId]?.attr(attrName) + if (value.isNullOrEmpty()) "__UNDEFINED__" else value + } + + function("__cheerio_next") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "__NONE__" + val next = element.nextElementSibling() ?: return@function "__NONE__" + val nextId = "$docId:next:${next.hashCode()}" + elementCache[nextId] = next + nextId + } + + function("__cheerio_prev") { args -> + val docId = args.getOrNull(0)?.toString() ?: "" + val elementId = args.getOrNull(1)?.toString() ?: "" + val element = elementCache[elementId] ?: return@function "__NONE__" + val prev = element.previousElementSibling() ?: return@function "__NONE__" + val prevId = "$docId:prev:${prev.hashCode()}" + elementCache[prevId] = prev + prevId + } + + function("__capture_result") { args -> + resultJson = args.getOrNull(0)?.toString() ?: "[]" + null + } + + val settingsJson = toJsonElement(scraperSettings).toString() + val polyfillCode = buildPolyfillCode(scraperId, settingsJson) + evaluate(polyfillCode) + + val wrappedCode = """ + var module = { exports: {} }; + var exports = module.exports; + (function() { + $code + })(); + """.trimIndent() + evaluate(wrappedCode) + + val seasonArg = season?.toString() ?: "undefined" + val episodeArg = episode?.toString() ?: "undefined" + val callCode = """ + (async function() { + try { + var getStreams = module.exports.getStreams || globalThis.getStreams; + if (!getStreams) { + console.error("getStreams function not found on module.exports or globalThis"); + __capture_result(JSON.stringify([])); + return; + } + var result = await getStreams("$tmdbId", "$mediaType", $seasonArg, $episodeArg); + __capture_result(JSON.stringify(result || [])); + } catch (e) { + console.error("getStreams error:", e && e.message ? e.message : e, e && e.stack ? e.stack : ""); + __capture_result(JSON.stringify([])); + } + })(); + """.trimIndent() + evaluate(callCode) + } + + return parseJsonResults(resultJson) + } finally { + documentCache.clear() + elementCache.clear() + } + } + + private fun performNativeFetch( + url: String, + method: String, + headersJson: String, + body: String, + ): String { + return try { + val headers = parseHeaders(headersJson).toMutableMap() + if (!headers.containsKey("User-Agent")) { + headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + } + + val response = runBlocking { + httpRequestRaw( + method = method, + url = url, + headers = headers, + body = body, + ) + } + + val responseHeaders = response.headers.mapValues { (_, value) -> + truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS) + } + val result = JsonObject( + mapOf( + "ok" to JsonPrimitive(response.status in 200..299), + "status" to JsonPrimitive(response.status), + "statusText" to JsonPrimitive(response.statusText), + "url" to JsonPrimitive(response.url), + "body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)), + "headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }), + ), + ) + result.toString() + } catch (error: Throwable) { + log.e(error) { "Fetch error for $method $url" } + JsonObject( + mapOf( + "ok" to JsonPrimitive(false), + "status" to JsonPrimitive(0), + "statusText" to JsonPrimitive(error.message ?: "Fetch failed"), + "url" to JsonPrimitive(url), + "body" to JsonPrimitive(""), + "headers" to JsonObject(emptyMap()), + ), + ) + .toString() + } + } + + private fun parseHeaders(headersJson: String): Map { + return runCatching { + val obj = json.parseToJsonElement(headersJson) as? JsonObject ?: JsonObject(emptyMap()) + obj.entries + .mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + .toMap() + }.getOrDefault(emptyMap()) + } + + private fun parseUrl(urlString: String): String { + return try { + val parsed = io.ktor.http.Url(urlString) + JsonObject( + mapOf( + "protocol" to JsonPrimitive("${parsed.protocol.name}:"), + "host" to JsonPrimitive( + if (parsed.port != parsed.protocol.defaultPort) { + "${parsed.host}:${parsed.port}" + } else { + parsed.host + }, + ), + "hostname" to JsonPrimitive(parsed.host), + "port" to JsonPrimitive( + if (parsed.port != parsed.protocol.defaultPort) parsed.port.toString() else "", + ), + "pathname" to JsonPrimitive(parsed.encodedPath.ifBlank { "/" }), + "search" to JsonPrimitive(parsed.encodedQuery?.let { "?$it" } ?: ""), + "hash" to JsonPrimitive(parsed.encodedFragment?.let { "#$it" } ?: ""), + ), + ).toString() + } catch (_: Exception) { + JsonObject( + mapOf( + "protocol" to JsonPrimitive(""), + "host" to JsonPrimitive(""), + "hostname" to JsonPrimitive(""), + "port" to JsonPrimitive(""), + "pathname" to JsonPrimitive("/"), + "search" to JsonPrimitive(""), + "hash" to JsonPrimitive(""), + ), + ).toString() + } + } + + private fun truncateString(value: String, maxChars: Int): String { + if (value.length <= maxChars) return value + val end = maxChars - FETCH_TRUNCATION_SUFFIX.length + if (end <= 0) return FETCH_TRUNCATION_SUFFIX.take(maxChars) + return value.substring(0, end) + FETCH_TRUNCATION_SUFFIX + } + + private fun parseJsonResults(rawJson: String): List { + return runCatching { + val array = json.parseToJsonElement(rawJson) as? JsonArray ?: return emptyList() + array.mapNotNull { element -> + val item = element as? JsonObject ?: return@mapNotNull null + val url = when (val urlValue = item["url"]) { + is JsonPrimitive -> urlValue.contentOrNull?.takeIf { it.isNotBlank() } + is JsonObject -> urlValue["url"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } + else -> null + } ?: return@mapNotNull null + + val headers = (item["headers"] as? JsonObject) + ?.mapNotNull { (key, value) -> + value.jsonPrimitive.contentOrNull?.let { key to it } + } + ?.toMap() + ?.takeIf { it.isNotEmpty() } + + PluginRuntimeResult( + title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown", + name = item.stringOrNull("name"), + url = url, + quality = item.stringOrNull("quality"), + size = item.stringOrNull("size"), + language = item.stringOrNull("language"), + provider = item.stringOrNull("provider"), + type = item.stringOrNull("type"), + seeders = item["seeders"]?.jsonPrimitive?.intOrNull, + peers = item["peers"]?.jsonPrimitive?.intOrNull, + infoHash = item.stringOrNull("infoHash"), + headers = headers, + ) + }.filter { it.url.isNotBlank() } + }.getOrElse { error -> + log.e(error) { "Failed to parse plugin result json" } + emptyList() + } + } + + private fun JsonObject.stringOrNull(key: String): String? = + this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() && !it.contains("[object") } + + private fun toJsonElement(value: Any?): JsonElement = when (value) { + null -> JsonNull + is JsonElement -> value + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is Float -> JsonPrimitive(value) + is Double -> JsonPrimitive(value) + is Number -> JsonPrimitive(value.toDouble()) + is Map<*, *> -> JsonObject( + value.entries + .filter { it.key is String } + .associate { (it.key as String) to toJsonElement(it.value) }, + ) + is Iterable<*> -> JsonArray(value.map(::toJsonElement)) + else -> JsonPrimitive(value.toString()) + } + + private fun buildPolyfillCode(scraperId: String, settingsJson: String): String { + return """ + globalThis.SCRAPER_ID = "$scraperId"; + globalThis.SCRAPER_SETTINGS = $settingsJson; + if (typeof globalThis.global === 'undefined') globalThis.global = globalThis; + if (typeof globalThis.window === 'undefined') globalThis.window = globalThis; + if (typeof globalThis.self === 'undefined') globalThis.self = globalThis; + + var fetch = async function(url, options) { + options = options || {}; + var method = (options.method || 'GET').toUpperCase(); + var headers = options.headers || {}; + var body = options.body || ''; + var result = __native_fetch(url, method, JSON.stringify(headers), body); + var parsed = JSON.parse(result); + return { + ok: parsed.ok, + status: parsed.status, + statusText: parsed.statusText, + url: parsed.url, + headers: { + get: function(name) { + return parsed.headers[name.toLowerCase()] || null; + } + }, + text: function() { return Promise.resolve(parsed.body); }, + json: function() { + try { + if (parsed.body === null || parsed.body === undefined || parsed.body === '') { + return Promise.resolve(null); + } + return Promise.resolve(JSON.parse(parsed.body)); + } catch (e) { + return Promise.resolve(null); + } + } + }; + }; + + if (typeof AbortSignal === 'undefined') { + var AbortSignal = function() { this.aborted = false; this.reason = undefined; this._listeners = []; }; + AbortSignal.prototype.addEventListener = function(type, listener) { + if (type !== 'abort' || typeof listener !== 'function') return; + this._listeners.push(listener); + }; + AbortSignal.prototype.removeEventListener = function(type, listener) { + if (type !== 'abort') return; + this._listeners = this._listeners.filter(function(l) { return l !== listener; }); + }; + AbortSignal.prototype.dispatchEvent = function(event) { + if (!event || event.type !== 'abort') return true; + for (var i = 0; i < this._listeners.length; i++) { + try { this._listeners[i].call(this, event); } catch (e) {} + } + return true; + }; + globalThis.AbortSignal = AbortSignal; + } + + if (typeof AbortController === 'undefined') { + var AbortController = function() { this.signal = new AbortSignal(); }; + AbortController.prototype.abort = function(reason) { + if (this.signal.aborted) return; + this.signal.aborted = true; + this.signal.reason = reason; + this.signal.dispatchEvent({ type: 'abort' }); + }; + globalThis.AbortController = AbortController; + } + + if (typeof atob === 'undefined') { + globalThis.atob = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input).replace(/=+$/, ''); + if (str.length % 4 === 1) throw new Error('InvalidCharacterError'); + var output = ''; + var bc = 0, bs, buffer, idx = 0; + while ((buffer = str.charAt(idx++))) { + buffer = chars.indexOf(buffer); + if (buffer === -1) continue; + bs = bc % 4 ? bs * 64 + buffer : buffer; + if (bc++ % 4) output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))); + } + return output; + }; + } + + if (typeof btoa === 'undefined') { + globalThis.btoa = function(input) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var str = String(input); + var output = ''; + for (var block, charCode, idx = 0, map = chars; + str.charAt(idx | 0) || (map = '=', idx % 1); + output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))) { + charCode = str.charCodeAt(idx += 3 / 4); + if (charCode > 0xFF) throw new Error('InvalidCharacterError'); + block = (block << 8) | charCode; + } + return output; + }; + } + + var URL = function(urlString, base) { + var fullUrl = urlString; + if (base && !/^https?:\/\//i.test(urlString)) { + var b = typeof base === 'string' ? base : base.href; + if (urlString.charAt(0) === '/') { + var m = b.match(/^(https?:\/\/[^\/]+)/); + fullUrl = m ? m[1] + urlString : urlString; + } else { + fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString; + } + } + var parsed = __parse_url(fullUrl); + var data = JSON.parse(parsed); + this.href = fullUrl; + this.protocol = data.protocol; + this.host = data.host; + this.hostname = data.hostname; + this.port = data.port; + this.pathname = data.pathname; + this.search = data.search; + this.hash = data.hash; + this.origin = data.protocol + '//' + data.host; + this.searchParams = new URLSearchParams(data.search || ''); + }; + URL.prototype.toString = function() { return this.href; }; + + var URLSearchParams = function(init) { + this._params = {}; + var self = this; + if (init && typeof init === 'object' && !Array.isArray(init)) { + Object.keys(init).forEach(function(key) { self._params[key] = String(init[key]); }); + } else if (typeof init === 'string') { + init.replace(/^\?/, '').split('&').forEach(function(pair) { + var parts = pair.split('='); + if (parts[0]) self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || ''); + }); + } + }; + URLSearchParams.prototype.toString = function() { + var self = this; + return Object.keys(this._params).map(function(key) { + return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]); + }).join('&'); + }; + URLSearchParams.prototype.get = function(key) { return this._params.hasOwnProperty(key) ? this._params[key] : null; }; + URLSearchParams.prototype.set = function(key, value) { this._params[key] = String(value); }; + URLSearchParams.prototype.append = function(key, value) { this._params[key] = String(value); }; + URLSearchParams.prototype.has = function(key) { return this._params.hasOwnProperty(key); }; + URLSearchParams.prototype.delete = function(key) { delete this._params[key]; }; + URLSearchParams.prototype.keys = function() { return Object.keys(this._params); }; + URLSearchParams.prototype.values = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return self._params[k]; }); + }; + URLSearchParams.prototype.entries = function() { + var self = this; + return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; }); + }; + URLSearchParams.prototype.forEach = function(callback) { + var self = this; + Object.keys(this._params).forEach(function(key) { callback(self._params[key], key, self); }); + }; + URLSearchParams.prototype.getAll = function(key) { + return this._params.hasOwnProperty(key) ? [this._params[key]] : []; + }; + URLSearchParams.prototype.sort = function() { + var sorted = {}; + var self = this; + Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; }); + this._params = sorted; + }; + + var cheerio = { + load: function(html) { + var docId = __cheerio_load(html); + var $ = function(selector, context) { + if (selector && selector._elementIds) return selector; + if (context && context._elementIds && context._elementIds.length > 0) { + var allIds = []; + for (var i = 0; i < context._elementIds.length; i++) { + var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + } + return createCheerioWrapper(docId, selector); + }; + $.html = function(el) { + if (el && el._elementIds && el._elementIds.length > 0) { + return __cheerio_html(docId, el._elementIds[0]); + } + return __cheerio_html(docId, ''); + }; + return $; + } + }; + + function createCheerioWrapper(docId, selector) { + var elementIds; + if (typeof selector === 'string') { + var idsJson = __cheerio_select(docId, selector); + elementIds = JSON.parse(idsJson); + } else { + elementIds = []; + } + return createCheerioWrapperFromIds(docId, elementIds); + } + + function createCheerioWrapperFromIds(docId, ids) { + var wrapper = { + _docId: docId, + _elementIds: ids, + length: ids.length, + each: function(callback) { + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + callback.call(elWrapper, i, elWrapper); + } + return wrapper; + }, + find: function(sel) { + var allIds = []; + for (var i = 0; i < ids.length; i++) { + var childIdsJson = __cheerio_find(docId, ids[i], sel); + var childIds = JSON.parse(childIdsJson); + allIds = allIds.concat(childIds); + } + return createCheerioWrapperFromIds(docId, allIds); + }, + text: function() { + if (ids.length === 0) return ''; + return __cheerio_text(docId, ids.join(',')); + }, + html: function() { + if (ids.length === 0) return ''; + return __cheerio_inner_html(docId, ids[0]); + }, + attr: function(name) { + if (ids.length === 0) return undefined; + var val = __cheerio_attr(docId, ids[0], name); + return val === '__UNDEFINED__' ? undefined : val; + }, + first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); }, + last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); }, + next: function() { + var nextIds = []; + for (var i = 0; i < ids.length; i++) { + var nextId = __cheerio_next(docId, ids[i]); + if (nextId && nextId !== '__NONE__') nextIds.push(nextId); + } + return createCheerioWrapperFromIds(docId, nextIds); + }, + prev: function() { + var prevIds = []; + for (var i = 0; i < ids.length; i++) { + var prevId = __cheerio_prev(docId, ids[i]); + if (prevId && prevId !== '__NONE__') prevIds.push(prevId); + } + return createCheerioWrapperFromIds(docId, prevIds); + }, + eq: function(index) { + if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); + return createCheerioWrapperFromIds(docId, []); + }, + get: function(index) { + if (typeof index === 'number') { + if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]); + return undefined; + } + return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); + }, + map: function(callback) { + var results = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = callback.call(elWrapper, i, elWrapper); + if (result !== undefined && result !== null) results.push(result); + } + return { + length: results.length, + get: function(index) { return typeof index === 'number' ? results[index] : results; }, + toArray: function() { return results; } + }; + }, + filter: function(selectorOrCallback) { + if (typeof selectorOrCallback === 'function') { + var filteredIds = []; + for (var i = 0; i < ids.length; i++) { + var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]); + var result = selectorOrCallback.call(elWrapper, i, elWrapper); + if (result) filteredIds.push(ids[i]); + } + return createCheerioWrapperFromIds(docId, filteredIds); + } + return wrapper; + }, + children: function(sel) { return this.find(sel || '*'); }, + parent: function() { return createCheerioWrapperFromIds(docId, []); }, + toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); } + }; + return wrapper; + } + + var require = function(moduleName) { + if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') { + return cheerio; + } + throw new Error("Module '" + moduleName + "' is not available"); + }; + + if (!Array.prototype.flat) { + Array.prototype.flat = function(depth) { + depth = depth === undefined ? 1 : Math.floor(depth); + if (depth < 1) return Array.prototype.slice.call(this); + return (function flatten(arr, d) { + return d > 0 + ? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, []) + : arr.slice(); + })(this, depth); + }; + } + + if (!Array.prototype.flatMap) { + Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); }; + } + + if (!Object.entries) { + Object.entries = function(obj) { + var result = []; + for (var key in obj) { + if (obj.hasOwnProperty(key)) result.push([key, obj[key]]); + } + return result; + }; + } + + if (!Object.fromEntries) { + Object.fromEntries = function(entries) { + var result = {}; + for (var i = 0; i < entries.length; i++) { + result[entries[i][0]] = entries[i][1]; + } + return result; + }; + } + + if (!String.prototype.replaceAll) { + String.prototype.replaceAll = function(search, replace) { + if (search instanceof RegExp) { + if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp'); + return this.replace(search, replace); + } + return this.split(search).join(replace); + }; + } + """.trimIndent() + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt new file mode 100644 index 00000000..2cb80ae2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -0,0 +1,366 @@ +package com.nuvio.app.features.plugins + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioIconActionButton +import com.nuvio.app.core.ui.NuvioInfoBadge +import com.nuvio.app.core.ui.NuvioInputField +import com.nuvio.app.core.ui.NuvioPrimaryButton +import com.nuvio.app.core.ui.NuvioSectionLabel +import com.nuvio.app.core.ui.NuvioSurfaceCard +import kotlinx.coroutines.launch + +@Composable +fun PluginsSettingsPageContent( + modifier: Modifier = Modifier, +) { + LaunchedEffect(Unit) { + PluginRepository.initialize() + } + + val uiState by PluginRepository.uiState.collectAsStateWithLifecycle() + val coroutineScope = rememberCoroutineScope() + + var repositoryUrl by rememberSaveable { mutableStateOf("") } + var message by rememberSaveable { mutableStateOf(null) } + var isAdding by remember { mutableStateOf(false) } + + var testingScraperId by remember { mutableStateOf(null) } + val testResults = remember { mutableStateMapOf>() } + + val sortedRepos = remember(uiState.repositories) { + uiState.repositories.sortedBy { it.name.lowercase() } + } + val sortedScrapers = remember(uiState.scrapers) { + uiState.scrapers.sortedBy { it.name.lowercase() } + } + + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + NuvioSectionLabel("OVERVIEW") + NuvioSurfaceCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + NuvioInfoBadge(text = "${sortedRepos.size} repos") + NuvioInfoBadge(text = "${sortedScrapers.size} scrapers") + NuvioInfoBadge( + text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled", + ) + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Enable plugin scrapers globally", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Switch( + checked = uiState.pluginsEnabled, + onCheckedChange = { PluginRepository.setPluginsEnabled(it) }, + ) + } + } + + NuvioSectionLabel("ADD REPOSITORY") + NuvioSurfaceCard { + NuvioInputField( + value = repositoryUrl, + onValueChange = { + repositoryUrl = it + message = null + }, + placeholder = "Plugin manifest URL", + ) + Spacer(modifier = Modifier.height(16.dp)) + NuvioPrimaryButton( + text = if (isAdding) "Installing..." else "Install Plugin Repository", + enabled = repositoryUrl.isNotBlank() && !isAdding, + onClick = { + val requested = repositoryUrl.trim() + if (requested.isBlank()) { + message = "Enter a plugin repository URL." + return@NuvioPrimaryButton + } + isAdding = true + message = null + coroutineScope.launch { + when (val result = PluginRepository.addRepository(requested)) { + is AddPluginRepositoryResult.Success -> { + repositoryUrl = "" + message = "Installed ${result.repository.name}." + } + is AddPluginRepositoryResult.Error -> { + message = result.message + } + } + isAdding = false + } + }, + ) + message?.let { text -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + NuvioSectionLabel("INSTALLED REPOSITORIES") + if (sortedRepos.isEmpty()) { + NuvioSurfaceCard { + Text( + text = "No plugin repositories installed yet.", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Add a repository URL to download JS scrapers and use them in stream discovery.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + sortedRepos.forEach { repo -> + NuvioSurfaceCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = repo.name, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + repo.version?.let { version -> + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Version $version", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = repo.manifestUrl, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + NuvioIconActionButton( + icon = Icons.Rounded.Refresh, + contentDescription = "Refresh plugin repository", + tint = MaterialTheme.colorScheme.primary, + onClick = { PluginRepository.refreshRepository(repo.manifestUrl, pushAfterRefresh = true) }, + ) + NuvioIconActionButton( + icon = Icons.Rounded.Delete, + contentDescription = "Delete plugin repository", + tint = MaterialTheme.colorScheme.error, + onClick = { PluginRepository.removeRepository(repo.manifestUrl) }, + ) + } + } + Spacer(modifier = Modifier.height(14.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + NuvioInfoBadge(text = "${repo.scraperCount} scrapers") + if (repo.isRefreshing) { + NuvioInfoBadge(text = "Refreshing") + } + } + repo.errorMessage?.let { errorMessage -> + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + NuvioSectionLabel("SCRAPERS") + if (sortedScrapers.isEmpty()) { + NuvioSurfaceCard { + Text( + text = "No scrapers available yet.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + sortedScrapers.forEach { scraper -> + val scraperResults = testResults[scraper.id] + val isTestingThisScraper = testingScraperId == scraper.id + + NuvioSurfaceCard { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top, + ) { + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.Extension, + contentDescription = null, + tint = if (scraper.enabled) Color(0xFF68B76A) else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = scraper.name, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = scraper.description.ifBlank { "No description" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Switch( + checked = scraper.enabled, + onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) }, + enabled = scraper.manifestEnabled, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + NuvioInfoBadge(text = scraper.supportedTypes.joinToString(" | ")) + NuvioInfoBadge(text = "v${scraper.version}") + } + + Spacer(modifier = Modifier.height(12.dp)) + NuvioPrimaryButton( + text = if (isTestingThisScraper) "Testing..." else "Test Scraper", + enabled = !isTestingThisScraper, + onClick = { + testingScraperId = scraper.id + coroutineScope.launch { + PluginRepository.testScraper(scraper.id) + .onSuccess { results -> + testResults[scraper.id] = results + } + .onFailure { error -> + testResults[scraper.id] = listOf( + PluginRuntimeResult( + title = "Error", + name = error.message ?: "Scraper test failed", + url = "about:error", + ), + ) + } + testingScraperId = null + } + }, + ) + + if (!scraperResults.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outline) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = "Test results (${scraperResults.size})", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + scraperResults.take(8).forEach { result -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + Icon( + imageVector = Icons.Rounded.Bolt, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = result.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = result.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Spacer(modifier = Modifier.height(6.dp)) + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index b5c6c4bb..1279c8a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -9,6 +9,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository @@ -122,6 +123,7 @@ object ProfileRepository { LibraryRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) + PluginRepository.onProfileChanged(profileIndex) ThemeSettingsRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged() HomeCatalogSettingsRepository.onProfileChanged() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index 5f58804f..666dd943 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -3,11 +3,13 @@ package com.nuvio.app.features.settings import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Hub import androidx.compose.material.icons.rounded.Tune internal fun LazyListScope.contentDiscoveryContent( isTablet: Boolean, onAddonsClick: () -> Unit, + onPluginsClick: () -> Unit, onHomescreenClick: () -> Unit, ) { item { @@ -23,6 +25,13 @@ internal fun LazyListScope.contentDiscoveryContent( isTablet = isTablet, onClick = onAddonsClick, ) + SettingsNavigationRow( + title = "Plugins", + description = "Install JavaScript scraper repositories and test providers internally.", + icon = Icons.Rounded.Hub, + isTablet = isTablet, + onClick = onPluginsClick, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt new file mode 100644 index 00000000..9550b9c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.fillMaxWidth +import com.nuvio.app.features.plugins.PluginsSettingsPageContent + +internal fun LazyListScope.pluginsSettingsContent() { + item { + PluginsSettingsPageContent( + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index 4cf840e6..c313b82b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -102,6 +103,29 @@ fun AddonsSettingsScreen( } } +@Composable +fun PluginsSettingsScreen( + onBack: () -> Unit, +) { + LaunchedEffect(Unit) { + PluginRepository.initialize() + } + + NuvioScreen( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + stickyHeader { + NuvioScreenHeader( + title = "Plugins", + onBack = onBack, + ) + } + pluginsSettingsContent() + } +} + @Composable fun AccountSettingsScreen( onBack: () -> Unit, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 209187a5..257d4d47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -53,6 +53,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = ContentDiscovery, ), + Plugins( + title = "Plugins", + category = SettingsCategory.General, + parentPage = ContentDiscovery, + ), Homescreen( title = "Homescreen", category = SettingsCategory.General, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index ea434450..2b57268e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -57,6 +57,7 @@ fun SettingsScreen( onHomescreenClick: () -> Unit = {}, onContinueWatchingClick: () -> Unit = {}, onAddonsClick: () -> Unit = {}, + onPluginsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, ) { BoxWithConstraints( @@ -165,6 +166,7 @@ fun SettingsScreen( onHomescreenClick = onHomescreenClick, onContinueWatchingClick = onContinueWatchingClick, onAddonsClick = onAddonsClick, + onPluginsClick = onPluginsClick, onAccountClick = onAccountClick, ) } @@ -199,6 +201,7 @@ private fun MobileSettingsScreen( onHomescreenClick: () -> Unit = {}, onContinueWatchingClick: () -> Unit = {}, onAddonsClick: () -> Unit = {}, + onPluginsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, ) { NuvioScreen { @@ -253,9 +256,11 @@ private fun MobileSettingsScreen( SettingsPage.ContentDiscovery -> contentDiscoveryContent( isTablet = false, onAddonsClick = onAddonsClick, + onPluginsClick = onPluginsClick, onHomescreenClick = onHomescreenClick, ) SettingsPage.Addons -> addonsSettingsContent() + SettingsPage.Plugins -> pluginsSettingsContent() SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = false, heroEnabled = homescreenHeroEnabled, @@ -430,9 +435,11 @@ private fun TabletSettingsScreen( SettingsPage.ContentDiscovery -> contentDiscoveryContent( isTablet = true, onAddonsClick = { openInlinePage(SettingsPage.Addons) }, + onPluginsClick = { openInlinePage(SettingsPage.Plugins) }, onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, ) SettingsPage.Addons -> addonsSettingsContent() + SettingsPage.Plugins -> pluginsSettingsContent() SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = true, heroEnabled = homescreenHeroEnabled, 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 14775db8..a1777539 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 @@ -4,6 +4,9 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.httpGetText import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.plugins.PluginRepository +import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.PluginScraper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -25,16 +28,16 @@ object StreamsRepository { private var activeRequestKey: String? = null - fun load(type: String, videoId: String) { - load(type = type, videoId = videoId, forceRefresh = false) + fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null) { + load(type = type, videoId = videoId, season = season, episode = episode, forceRefresh = false) } - fun reload(type: String, videoId: String) { - load(type = type, videoId = videoId, forceRefresh = true) + fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null) { + load(type = type, videoId = videoId, season = season, episode = episode, forceRefresh = true) } - private fun load(type: String, videoId: String, forceRefresh: Boolean) { - val requestKey = "$type::$videoId" + private fun load(type: String, videoId: String, season: Int?, episode: Int?, forceRefresh: Boolean) { + val requestKey = "$type::$videoId::$season::$episode" val currentState = _uiState.value if ( !forceRefresh && @@ -67,7 +70,10 @@ object StreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons - if (installedAddons.isEmpty()) { + PluginRepository.initialize() + val pluginScrapers = PluginRepository.getEnabledScrapersForType(type) + + if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) { _uiState.value = StreamsUiState( isAnyLoading = false, emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled, @@ -88,7 +94,7 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty()) { + if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) { _uiState.value = StreamsUiState( isAnyLoading = false, emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons, @@ -104,16 +110,23 @@ object StreamsRepository { streams = emptyList(), isLoading = true, ) + } + pluginScrapers.map { scraper -> + AddonStreamGroup( + addonName = scraper.name, + addonId = "plugin:${scraper.id}", + streams = emptyList(), + isLoading = true, + ) } _uiState.value = StreamsUiState( groups = initialGroups, - activeAddonIds = streamAddons.map { it.id }.toSet(), + activeAddonIds = initialGroups.map { it.addonId }.toSet(), isAnyLoading = true, emptyStateReason = null, ) activeJob = scope.launch { - val jobs = streamAddons.map { manifest -> + val addonJobs = streamAddons.map { manifest -> async { val encodedId = videoId.encodeForPath() val baseUrl = manifest.transportUrl @@ -153,6 +166,38 @@ object StreamsRepository { } } + val pluginJobs = pluginScrapers.map { scraper -> + async { + PluginRepository.executeScraper( + scraper = scraper, + tmdbId = videoId.toPluginTmdbId(), + mediaType = type, + season = season, + episode = episode, + ).fold( + onSuccess = { results -> + AddonStreamGroup( + addonName = scraper.name, + addonId = "plugin:${scraper.id}", + streams = results.map { it.toStreamItem(scraper) }, + isLoading = false, + ) + }, + onFailure = { error -> + AddonStreamGroup( + addonName = scraper.name, + addonId = "plugin:${scraper.id}", + streams = emptyList(), + isLoading = false, + error = error.message, + ) + }, + ) + } + } + + val jobs = addonJobs + pluginJobs + // Collect results as they arrive and update state incrementally jobs.forEach { deferred -> val result = deferred.await() @@ -197,3 +242,27 @@ private fun List.toEmptyStateReason(anyLoading: Boolean): Stre StreamsEmptyStateReason.NoStreamsFound } } + +private fun String.toPluginTmdbId(): String { + return when { + startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this } + startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this } + else -> this + } +} + +private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem { + val subtitleParts = listOfNotNull( + quality?.takeIf { it.isNotBlank() }, + size?.takeIf { it.isNotBlank() }, + language?.takeIf { it.isNotBlank() }, + ) + return StreamItem( + name = name ?: title, + description = subtitleParts.joinToString(" • ").ifBlank { null }, + url = url, + infoHash = infoHash, + addonName = scraper.name, + addonId = "plugin:${scraper.id}", + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 4eacb6ea..9b3097c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -131,7 +131,12 @@ fun StreamsScreen( } LaunchedEffect(type, videoId) { - StreamsRepository.load(type, videoId) + StreamsRepository.load( + type = type, + videoId = videoId, + season = seasonNumber, + episode = episodeNumber, + ) } LaunchedEffect(uiState.groups, storedProgress?.providerAddonId, preferredFilterApplied) { @@ -210,7 +215,16 @@ fun StreamsScreen( color = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), shape = CircleShape, ) - .clickable(onClick = { StreamsRepository.reload(type, videoId) }), + .clickable( + onClick = { + StreamsRepository.reload( + type = type, + videoId = videoId, + season = seasonNumber, + episode = episodeNumber, + ) + }, + ), contentAlignment = Alignment.Center, ) { Icon( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 1a9c827e..41743e84 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -6,6 +6,7 @@ internal actual object PlatformLocalAccountDataCleaner { private val plainKeys = listOf("profile_payload") private val profileIndexedPrefixes = listOf( "installed_manifest_urls_", + "plugins_state_", "library_payload_", "watched_payload_", "watch_progress_payload_", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index 7fba9f34..9f84f0e1 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -7,10 +7,13 @@ import io.ktor.client.request.accept import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.post +import io.ktor.client.request.request import io.ktor.client.request.setBody +import io.ktor.client.request.url import io.ktor.client.statement.bodyAsText import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.HttpMethod import io.ktor.http.isSuccess import platform.Foundation.NSUserDefaults @@ -123,3 +126,32 @@ actual suspend fun httpPostJsonWithHeaders( } payload } + +actual suspend fun httpRequestRaw( + method: String, + url: String, + headers: Map, + body: String, +): RawHttpResponse = + addonHttpClient + .request { + url(url) + this.method = HttpMethod.parse(method.uppercase()) + headers.forEach { (key, value) -> + header(key, value) + } + if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) { + setBody(body) + } + } + .let { response -> + RawHttpResponse( + status = response.status.value, + statusText = response.status.description, + url = response.call.request.url.toString(), + body = response.bodyAsText(), + headers = response.headers.entries().associate { (name, values) -> + name.lowercase() to values.joinToString(",") + }, + ) + } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt new file mode 100644 index 00000000..6100a226 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.features.plugins + +import platform.Foundation.NSUserDefaults +import platform.Foundation.timeIntervalSince1970 + +internal actual object PluginStorage { + private const val pluginsStateKey = "plugins_state" + + actual fun loadState(profileId: Int): String? = + NSUserDefaults.standardUserDefaults.stringForKey("${pluginsStateKey}_$profileId") + + actual fun saveState(profileId: Int, payload: String) { + NSUserDefaults.standardUserDefaults.setObject( + payload, + forKey = "${pluginsStateKey}_$profileId", + ) + } +} + +internal actual fun currentPluginPlatform(): String = "ios" + +internal actual fun currentEpochMillis(): Long = + (platform.Foundation.NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8df4c424..93389e42 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,8 @@ ktor = "3.4.1" material3 = "1.10.0-alpha05" androidx-media3 = "1.10.0-rc01" supabase = "3.4.1" +quickjsKt = "1.0.1" +ksoup = "0.2.6" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -62,6 +64,8 @@ androidx-media3-extractor = { module = "androidx.media3:media3-extractor", versi supabase-postgrest = { module = "io.github.jan-tennert.supabase:postgrest-kt", version.ref = "supabase" } supabase-auth = { module = "io.github.jan-tennert.supabase:auth-kt", version.ref = "supabase" } supabase-functions = { module = "io.github.jan-tennert.supabase:functions-kt", version.ref = "supabase" } +quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt" } +ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" }