diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 98c120b98..a5715293e 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -127,7 +127,7 @@ kotlin { } sourceSets { - commonMain { + val commonMain by getting { kotlin.srcDir(generatedRuntimeConfigDir) } androidMain.dependencies { @@ -183,6 +183,10 @@ kotlin { } } +dependencies { + debugImplementation(libs.compose.uiTooling) +} + configurations.all { exclude(group = "androidx.media3", module = "media3-exoplayer") exclude(group = "androidx.media3", module = "media3-ui") @@ -245,6 +249,3 @@ android { } } -dependencies { - debugImplementation(libs.compose.uiTooling) -} diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt new file mode 100644 index 000000000..8a4c66e90 --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt @@ -0,0 +1,62 @@ +package com.nuvio.app.features.plugins + +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi +import java.security.MessageDigest +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +internal fun pluginDigestHex(algorithm: String, data: String): String { + val normalized = algorithm.uppercase() + val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray()) + return digest.joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } +} + +internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { + val normalized = when (algorithm.uppercase()) { + "SHA1" -> "HmacSHA1" + "SHA256" -> "HmacSHA256" + "SHA512" -> "HmacSHA512" + "MD5" -> "HmacMD5" + else -> error("Unsupported HMAC algorithm: $algorithm") + } + val mac = Mac.getInstance(normalized) + mac.init(SecretKeySpec(key.encodeToByteArray(), normalized)) + val digest = mac.doFinal(data.encodeToByteArray()) + return digest.joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } +} + +@OptIn(ExperimentalEncodingApi::class) +internal fun pluginBase64Encode(data: String): String = + Base64.encode(data.encodeToByteArray()) + +@OptIn(ExperimentalEncodingApi::class) +internal fun pluginBase64Decode(data: String): String { + val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + val decoded = Base64.decode(normalized) + return decoded.decodeToString() +} + +internal fun pluginUtf8ToHex(value: String): String = + value.encodeToByteArray().joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } + +internal fun pluginHexToUtf8(hex: String): String { + val normalized = hex.trim().lowercase() + .replace(" ", "") + .removePrefix("0x") + if (normalized.isEmpty()) return "" + + val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" + val out = ByteArray(evenHex.length / 2) + for (index in out.indices) { + val part = evenHex.substring(index * 2, index * 2 + 2) + out[index] = part.toInt(16).toByte() + } + return out.decodeToString() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt rename to composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt similarity index 68% rename from composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt rename to composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt index f99c6bd4c..6e77db321 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -3,7 +3,7 @@ package com.nuvio.app.features.plugins import android.content.Context import android.content.SharedPreferences -internal actual object PluginStorage { +internal object PluginStorage { private const val preferencesName = "nuvio_plugins" private const val pluginsStateKey = "plugins_state" @@ -13,10 +13,10 @@ internal actual object PluginStorage { preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) } - actual fun loadState(profileId: Int): String? = + fun loadState(profileId: Int): String? = preferences?.getString("${pluginsStateKey}_$profileId", null) - actual fun saveState(profileId: Int, payload: String) { + fun saveState(profileId: Int, payload: String) { preferences ?.edit() ?.putString("${pluginsStateKey}_$profileId", payload) @@ -24,6 +24,6 @@ internal actual object PluginStorage { } } -internal actual fun currentPluginPlatform(): String = "android" +internal fun currentPluginPlatform(): String = "android" -internal actual fun currentEpochMillis(): Long = System.currentTimeMillis() +internal fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt new file mode 100644 index 000000000..aa721abdb --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -0,0 +1,566 @@ +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, +) + +actual 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()) + actual val uiState: StateFlow = _uiState.asStateFlow() + + private var initialized = false + private var pulledFromServer = false + private var currentProfileId = 1 + private val activeRefreshJobs = mutableMapOf() + + actual fun initialize() { + val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) + val shouldRefreshStoredRepos = !initialized || currentProfileId != effectiveProfileId + ensureStateLoadedForProfile(effectiveProfileId) + if (!shouldRefreshStoredRepos) return + + _uiState.value.repositories.forEach { repo -> + refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false) + } + } + + actual fun onProfileChanged(profileId: Int) { + val effectiveProfileId = resolveEffectiveProfileId(profileId) + if (effectiveProfileId == currentProfileId && initialized) return + + cancelActiveRefreshes() + currentProfileId = effectiveProfileId + initialized = false + pulledFromServer = false + _uiState.value = PluginsUiState() + } + + actual fun clearLocalState() { + cancelActiveRefreshes() + currentProfileId = 1 + initialized = false + pulledFromServer = false + _uiState.value = PluginsUiState() + } + + actual suspend fun pullFromServer(profileId: Int) { + val effectiveProfileId = resolveEffectiveProfileId(profileId) + ensureStateLoadedForProfile(effectiveProfileId) + 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, + groupStreamsByRepository = _uiState.value.groupStreamsByRepository, + repositories = nextRepos, + scrapers = nextScrapers, + ) + persist() + + urls.forEach { url -> + refreshRepository(url, pushAfterRefresh = false) + } + + pulledFromServer = true + initialized = true + }.onFailure { error -> + log.e(error) { "pullFromServer failed" } + } + } + + actual 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") + } + } + + actual 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() + } + + actual fun refreshAll() { + initialize() + _uiState.value.repositories.forEach { repo -> + refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false) + } + } + + actual fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean) { + refreshRepositoryInternal(manifestUrl, pushAfterRefresh, ensureInitialized = true) + } + + private fun refreshRepositoryInternal( + manifestUrl: String, + pushAfterRefresh: Boolean, + ensureInitialized: Boolean, + ) { + if (ensureInitialized) { + 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 + } + + actual 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() + } + + actual fun setPluginsEnabled(enabled: Boolean) { + initialize() + _uiState.update { it.copy(pluginsEnabled = enabled) } + persist() + } + + actual fun setGroupStreamsByRepository(enabled: Boolean) { + initialize() + _uiState.update { it.copy(groupStreamsByRepository = enabled) } + persist() + } + + actual fun getEnabledScrapersForType(type: String): List { + initialize() + if (!_uiState.value.pluginsEnabled) return emptyList() + return _uiState.value.scrapers.filter { scraper -> + scraper.enabled && scraper.supportsType(type) + } + } + + actual suspend fun testScraper(scraperId: String): Result> { + initialize() + val scraper = _uiState.value.scrapers.find { it.id == scraperId } + ?: return Result.failure(IllegalArgumentException("Provider 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, + ) + } + + actual 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, + groupStreamsByRepository = state.groupStreamsByRepository, + 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 ensureStateLoadedForProfile(profileId: Int) { + if (initialized && currentProfileId == profileId) return + + if (currentProfileId != profileId) { + cancelActiveRefreshes() + pulledFromServer = false + } + + currentProfileId = profileId + _uiState.value = loadStateAsUiState(profileId) + initialized = true + } + + private fun loadStateAsUiState(profileId: Int): PluginsUiState { + val stored = loadStoredState(profileId) + return PluginsUiState( + pluginsEnabled = stored?.pluginsEnabled ?: true, + groupStreamsByRepository = stored?.groupStreamsByRepository ?: false, + 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(), + ) + } + + 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/androidFull/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt rename to composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt rename to composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt new file mode 100644 index 000000000..d7e2cb5ad --- /dev/null +++ b/composeApp/src/androidFull/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 actual fun LazyListScope.pluginsSettingsContent() { + item { + PluginsSettingsPageContent( + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt deleted file mode 100644 index d2cf117d3..000000000 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.android.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.nuvio.app.features.plugins - -import java.security.MessageDigest -import javax.crypto.Mac -import javax.crypto.spec.SecretKeySpec - -internal actual fun pluginDigestHex(algorithm: String, data: String): String { - val normalized = algorithm.uppercase() - val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray()) - return digest.joinToString(separator = "") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } -} - -internal actual fun pluginHmacHex(algorithm: String, key: String, data: String): String { - val normalized = when (algorithm.uppercase()) { - "SHA1" -> "HmacSHA1" - "SHA256" -> "HmacSHA256" - "SHA512" -> "HmacSHA512" - "MD5" -> "HmacMD5" - else -> error("Unsupported HMAC algorithm: $algorithm") - } - val mac = Mac.getInstance(normalized) - mac.init(SecretKeySpec(key.encodeToByteArray(), normalized)) - val digest = mac.doFinal(data.encodeToByteArray()) - return digest.joinToString(separator = "") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } -} diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt new file mode 100644 index 000000000..5ba7eb8d0 --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.plugins + +import android.content.Context + +internal object PluginStorage { + fun initialize(context: Context) = Unit +} \ No newline at end of file diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginRepository.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginRepository.android.kt new file mode 100644 index 000000000..6139ed02b --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/plugins/PluginRepository.android.kt @@ -0,0 +1,48 @@ +package com.nuvio.app.features.plugins + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +actual object PluginRepository { + private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false)) + + actual val uiState: StateFlow = disabledState.asStateFlow() + + actual fun initialize() = Unit + + actual fun onProfileChanged(profileId: Int) = Unit + + actual fun clearLocalState() = Unit + + actual suspend fun pullFromServer(profileId: Int) = Unit + + actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult = + AddPluginRepositoryResult.Error("Plugins are not available in this build.") + + actual fun removeRepository(manifestUrl: String) = Unit + + actual fun refreshAll() = Unit + + actual fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean) = Unit + + actual fun toggleScraper(scraperId: String, enabled: Boolean) = Unit + + actual fun setPluginsEnabled(enabled: Boolean) = Unit + + actual fun setGroupStreamsByRepository(enabled: Boolean) = Unit + + actual fun getEnabledScrapersForType(type: String): List = emptyList() + + actual suspend fun testScraper(scraperId: String): Result> = + Result.failure(UnsupportedOperationException("Plugins are not available in this build.")) + + actual suspend fun executeScraper( + scraper: PluginScraper, + tmdbId: String, + mediaType: String, + season: Int?, + episode: Int?, + ): Result> = + Result.failure(UnsupportedOperationException("Plugins are not available in this build.")) +} \ No newline at end of file diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt new file mode 100644 index 000000000..cd672f9ea --- /dev/null +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.lazy.LazyListScope + +internal actual fun LazyListScope.pluginsSettingsContent() = Unit \ No newline at end of file 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 index 925dda065..6db6d052f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -1,311 +1,35 @@ 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, -) +expect object PluginRepository { + val uiState: StateFlow -@Serializable -private data class PluginPushItem( - val url: String, - val name: String = "", - val enabled: Boolean = true, - @SerialName("sort_order") val sortOrder: Int = 0, -) + fun initialize() -object PluginRepository { - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val log = Logger.withTag("PluginRepository") - private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + fun onProfileChanged(profileId: Int) - private val _uiState = MutableStateFlow(PluginsUiState()) - val uiState: StateFlow = _uiState.asStateFlow() + fun clearLocalState() - private var initialized = false - private var pulledFromServer = false - private var currentProfileId = 1 - private val activeRefreshJobs = mutableMapOf() + suspend fun pullFromServer(profileId: Int) - fun initialize() { - val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) - val shouldRefreshStoredRepos = !initialized || currentProfileId != effectiveProfileId - ensureStateLoadedForProfile(effectiveProfileId) - if (!shouldRefreshStoredRepos) return + suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult - _uiState.value.repositories.forEach { repo -> - refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false) - } - } + fun removeRepository(manifestUrl: String) - fun onProfileChanged(profileId: Int) { - val effectiveProfileId = resolveEffectiveProfileId(profileId) - if (effectiveProfileId == currentProfileId && initialized) return + fun refreshAll() - cancelActiveRefreshes() - currentProfileId = effectiveProfileId - initialized = false - pulledFromServer = false - _uiState.value = PluginsUiState() - } + fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean = false) - fun clearLocalState() { - cancelActiveRefreshes() - currentProfileId = 1 - initialized = false - pulledFromServer = false - _uiState.value = PluginsUiState() - } + fun toggleScraper(scraperId: String, enabled: Boolean) - suspend fun pullFromServer(profileId: Int) { - val effectiveProfileId = resolveEffectiveProfileId(profileId) - ensureStateLoadedForProfile(effectiveProfileId) - runCatching { - val rows = SupabaseProvider.client.postgrest - .from("plugins") - .select { - filter { eq("profile_id", currentProfileId) } - order("sort_order", Order.ASCENDING) - } - .decodeList() + fun setPluginsEnabled(enabled: Boolean) - 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 - } - } + fun setGroupStreamsByRepository(enabled: Boolean) - 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) - } + fun getEnabledScrapersForType(type: String): List - _uiState.value = PluginsUiState( - pluginsEnabled = _uiState.value.pluginsEnabled, - groupStreamsByRepository = _uiState.value.groupStreamsByRepository, - 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 -> - refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false) - } - } - - fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean = false) { - refreshRepositoryInternal(manifestUrl, pushAfterRefresh, ensureInitialized = true) - } - - private fun refreshRepositoryInternal( - manifestUrl: String, - pushAfterRefresh: Boolean, - ensureInitialized: Boolean, - ) { - if (ensureInitialized) { - 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 setGroupStreamsByRepository(enabled: Boolean) { - initialize() - _uiState.update { it.copy(groupStreamsByRepository = 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("Provider 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 testScraper(scraperId: String): Result> suspend fun executeScraper( scraper: PluginScraper, @@ -313,254 +37,5 @@ object PluginRepository { 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, - groupStreamsByRepository = state.groupStreamsByRepository, - 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 ensureStateLoadedForProfile(profileId: Int) { - if (initialized && currentProfileId == profileId) return - - if (currentProfileId != profileId) { - cancelActiveRefreshes() - pulledFromServer = false - } - - currentProfileId = profileId - _uiState.value = loadStateAsUiState(profileId) - initialized = true - } - - private fun loadStateAsUiState(profileId: Int): PluginsUiState { - val stored = loadStoredState(profileId) - return PluginsUiState( - pluginsEnabled = stored?.pluginsEnabled ?: true, - groupStreamsByRepository = stored?.groupStreamsByRepository ?: false, - 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(), - ) - } - - 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 - } -} + ): Result> +} \ No newline at end of file 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 index 9550b9c22..a08b4ac39 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt @@ -1,14 +1,5 @@ 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(), - ) - } -} +internal expect fun LazyListScope.pluginsSettingsContent() \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt index 403795fc4..335363619 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt @@ -4,7 +4,6 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.details.MetaDetails -import com.nuvio.app.features.plugins.currentEpochMillis import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json @@ -242,4 +241,4 @@ private fun toReviewModel(dto: TraktCommentDto): TraktCommentReview { ) } -private fun currentTimeMillis(): Long = currentEpochMillis() +private fun currentTimeMillis(): Long = TraktPlatformClock.nowEpochMs() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt rename to composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt new file mode 100644 index 000000000..d8b00417f --- /dev/null +++ b/composeApp/src/fullCommonMain/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 providers." } + return manifest + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt similarity index 100% rename from composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt rename to composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt new file mode 100644 index 000000000..aa721abdb --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -0,0 +1,566 @@ +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, +) + +actual 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()) + actual val uiState: StateFlow = _uiState.asStateFlow() + + private var initialized = false + private var pulledFromServer = false + private var currentProfileId = 1 + private val activeRefreshJobs = mutableMapOf() + + actual fun initialize() { + val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) + val shouldRefreshStoredRepos = !initialized || currentProfileId != effectiveProfileId + ensureStateLoadedForProfile(effectiveProfileId) + if (!shouldRefreshStoredRepos) return + + _uiState.value.repositories.forEach { repo -> + refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false) + } + } + + actual fun onProfileChanged(profileId: Int) { + val effectiveProfileId = resolveEffectiveProfileId(profileId) + if (effectiveProfileId == currentProfileId && initialized) return + + cancelActiveRefreshes() + currentProfileId = effectiveProfileId + initialized = false + pulledFromServer = false + _uiState.value = PluginsUiState() + } + + actual fun clearLocalState() { + cancelActiveRefreshes() + currentProfileId = 1 + initialized = false + pulledFromServer = false + _uiState.value = PluginsUiState() + } + + actual suspend fun pullFromServer(profileId: Int) { + val effectiveProfileId = resolveEffectiveProfileId(profileId) + ensureStateLoadedForProfile(effectiveProfileId) + 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, + groupStreamsByRepository = _uiState.value.groupStreamsByRepository, + repositories = nextRepos, + scrapers = nextScrapers, + ) + persist() + + urls.forEach { url -> + refreshRepository(url, pushAfterRefresh = false) + } + + pulledFromServer = true + initialized = true + }.onFailure { error -> + log.e(error) { "pullFromServer failed" } + } + } + + actual 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") + } + } + + actual 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() + } + + actual fun refreshAll() { + initialize() + _uiState.value.repositories.forEach { repo -> + refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false) + } + } + + actual fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean) { + refreshRepositoryInternal(manifestUrl, pushAfterRefresh, ensureInitialized = true) + } + + private fun refreshRepositoryInternal( + manifestUrl: String, + pushAfterRefresh: Boolean, + ensureInitialized: Boolean, + ) { + if (ensureInitialized) { + 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 + } + + actual 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() + } + + actual fun setPluginsEnabled(enabled: Boolean) { + initialize() + _uiState.update { it.copy(pluginsEnabled = enabled) } + persist() + } + + actual fun setGroupStreamsByRepository(enabled: Boolean) { + initialize() + _uiState.update { it.copy(groupStreamsByRepository = enabled) } + persist() + } + + actual fun getEnabledScrapersForType(type: String): List { + initialize() + if (!_uiState.value.pluginsEnabled) return emptyList() + return _uiState.value.scrapers.filter { scraper -> + scraper.enabled && scraper.supportsType(type) + } + } + + actual suspend fun testScraper(scraperId: String): Result> { + initialize() + val scraper = _uiState.value.scrapers.find { it.id == scraperId } + ?: return Result.failure(IllegalArgumentException("Provider 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, + ) + } + + actual 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, + groupStreamsByRepository = state.groupStreamsByRepository, + 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 ensureStateLoadedForProfile(profileId: Int) { + if (initialized && currentProfileId == profileId) return + + if (currentProfileId != profileId) { + cancelActiveRefreshes() + pulledFromServer = false + } + + currentProfileId = profileId + _uiState.value = loadStateAsUiState(profileId) + initialized = true + } + + private fun loadStateAsUiState(profileId: Int): PluginsUiState { + val stored = loadStoredState(profileId) + return PluginsUiState( + pluginsEnabled = stored?.pluginsEnabled ?: true, + groupStreamsByRepository = stored?.groupStreamsByRepository ?: false, + 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(), + ) + } + + 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/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt new file mode 100644 index 000000000..641d56ba1 --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -0,0 +1,987 @@ +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("__crypto_digest_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1)?.toString() ?: "" + runCatching { + pluginDigestHex(algorithm, data) + }.getOrDefault("") + } + + function("__crypto_hmac_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val key = args.getOrNull(1)?.toString() ?: "" + val data = args.getOrNull(2)?.toString() ?: "" + runCatching { + pluginHmacHex(algorithm, key, data) + }.getOrDefault("") + } + + function("__crypto_base64_encode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Encode(data) + }.getOrDefault("") + } + + function("__crypto_base64_decode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Decode(data) + }.getOrDefault("") + } + + function("__crypto_utf8_to_hex") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginUtf8ToHex(data) + }.getOrDefault("") + } + + function("__crypto_hex_to_utf8") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginHexToUtf8(data) + }.getOrDefault("") + } + + 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; + }; + + function __hexToWords(hex) { + var words = []; + for (var i = 0; i < hex.length; i += 8) { + var chunk = hex.substring(i, i + 8); + while (chunk.length < 8) chunk += '0'; + words.push(parseInt(chunk, 16) | 0); + } + return words; + } + + function __wordsToHex(words, sigBytes) { + var hex = ''; + for (var i = 0; i < sigBytes; i++) { + var word = words[i >>> 2] || 0; + var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; + var part = byte.toString(16); + if (part.length < 2) part = '0' + part; + hex += part; + } + return hex; + } + + function __wordArrayToHex(value) { + if (!value) return ''; + if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); + if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return __wordsToHex(value.words, value.sigBytes); + } + return __crypto_utf8_to_hex(String(value)); + } + + function __buildWordArray(hex, utf8Override) { + var normalizedHex = (hex || '').toLowerCase(); + if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; + var wordArray = { + __hex: normalizedHex, + __utf8: utf8Override !== undefined ? utf8Override : __crypto_hex_to_utf8(normalizedHex), + sigBytes: normalizedHex.length / 2, + words: __hexToWords(normalizedHex), + toString: function(encoder) { + if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; + if (encoder === CryptoJS.enc.Utf8) return this.__utf8; + if (encoder === CryptoJS.enc.Base64) return __crypto_base64_encode(this.__utf8); + return this.__hex; + }, + clamp: function() { + return this; + }, + concat: function(other) { + var otherHex = __wordArrayToHex(other); + this.__hex += otherHex; + this.__utf8 = __crypto_hex_to_utf8(this.__hex); + this.sigBytes = this.__hex.length / 2; + this.words = __hexToWords(this.__hex); + return this; + } + }; + return wordArray; + } + + function __wordArrayFromHex(hex) { + return __buildWordArray(hex, undefined); + } + + function __wordArrayFromUtf8(text) { + var utf8 = text == null ? '' : String(text); + return __buildWordArray(__crypto_utf8_to_hex(utf8), utf8); + } + + function __wordArrayFromBase64(base64) { + return __wordArrayFromUtf8(__crypto_base64_decode(base64 || '')); + } + + function __normalizeWordArrayInput(value) { + if (value && typeof value === 'object' && typeof value.__utf8 === 'string') { + return value.__utf8; + } + if (value && typeof value === 'object' && typeof value.__hex === 'string') { + return __crypto_hex_to_utf8(value.__hex); + } + if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)); + } + if (value == null) return ''; + return String(value); + } + + function __cryptoHashWordArray(algorithm, message) { + var utf8 = __normalizeWordArrayInput(message); + var hex = __crypto_digest_hex(algorithm, utf8); + return __wordArrayFromHex(hex); + } + + function __cryptoHmacWordArray(algorithm, message, key) { + var utf8Message = __normalizeWordArrayInput(message); + var utf8Key = __normalizeWordArrayInput(key); + var hex = __crypto_hmac_hex(algorithm, utf8Key, utf8Message); + return __wordArrayFromHex(hex); + } + + var CryptoJS = { + enc: { + Hex: { + stringify: function(wordArray) { + return __wordArrayToHex(wordArray); + }, + parse: function(hexStr) { + return __wordArrayFromHex(hexStr || ''); + } + }, + Utf8: { + stringify: function(wordArray) { + if (wordArray && typeof wordArray.__utf8 === 'string') return wordArray.__utf8; + if (wordArray && typeof wordArray.__hex === 'string') return __crypto_hex_to_utf8(wordArray.__hex); + return __normalizeWordArrayInput(wordArray); + }, + parse: function(text) { + return __wordArrayFromUtf8(text); + } + }, + Base64: { + stringify: function(wordArray) { + if (wordArray && typeof wordArray.__utf8 === 'string') { + return __crypto_base64_encode(wordArray.__utf8); + } + return __crypto_base64_encode(__normalizeWordArrayInput(wordArray)); + }, + parse: function(base64) { + return __wordArrayFromBase64(base64); + } + } + }, + MD5: function(message) { return __cryptoHashWordArray('MD5', message); }, + SHA1: function(message) { return __cryptoHashWordArray('SHA1', message); }, + SHA256: function(message) { return __cryptoHashWordArray('SHA256', message); }, + SHA512: function(message) { return __cryptoHashWordArray('SHA512', message); }, + HmacMD5: function(message, key) { return __cryptoHmacWordArray('MD5', message, key); }, + HmacSHA1: function(message, key) { return __cryptoHmacWordArray('SHA1', message, key); }, + HmacSHA256: function(message, key) { return __cryptoHmacWordArray('SHA256', message, key); }, + HmacSHA512: function(message, key) { return __cryptoHmacWordArray('SHA512', message, key); } + }; + globalThis.CryptoJS = CryptoJS; + + 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; + } + if (moduleName === 'crypto-js') { + return CryptoJS; + } + 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/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt new file mode 100644 index 000000000..4838ab51c --- /dev/null +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -0,0 +1,434 @@ +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 repositoryNameByUrl = remember(sortedRepos) { + sortedRepos.associate { it.manifestUrl to it.name } + } + val sortedScrapers = remember(uiState.scrapers, repositoryNameByUrl) { + uiState.scrapers.sortedWith( + compareBy( + { repositoryNameByUrl[it.repositoryUrl]?.lowercase() ?: it.repositoryUrl.lowercase() }, + { 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} providers") + 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, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Enable plugin providers globally", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Use plugin providers during stream discovery.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Switch( + checked = uiState.pluginsEnabled, + onCheckedChange = { PluginRepository.setPluginsEnabled(it) }, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Group plugin providers by repository", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "In Streams, show one provider per repository instead of one per source.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Switch( + checked = uiState.groupStreamsByRepository, + onCheckedChange = { PluginRepository.setGroupStreamsByRepository(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 install provider plugins for 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} providers") + 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("PROVIDERS") + if (sortedScrapers.isEmpty()) { + NuvioSurfaceCard { + Text( + text = "No providers available yet.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + sortedScrapers.forEach { scraper -> + val scraperResults = testResults[scraper.id] + val isTestingThisScraper = testingScraperId == scraper.id + val repositoryName = repositoryNameByUrl[scraper.repositoryUrl] + ?: scraper.repositoryUrl.fallbackRepositoryLabel() + + 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 = repositoryName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + 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}") + if (!scraper.manifestEnabled) { + NuvioInfoBadge(text = "Disabled by repo") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + NuvioPrimaryButton( + text = if (isTestingThisScraper) "Testing..." else "Test Provider", + 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 ?: "Provider 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)) + } + } + } + } + } + } +} + +private fun String.fallbackRepositoryLabel(): String { + val withoutQuery = substringBefore("?") + val withoutManifest = withoutQuery.removeSuffix("/manifest.json") + val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') + return host.ifBlank { + withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" } + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt new file mode 100644 index 000000000..d7e2cb5ad --- /dev/null +++ b/composeApp/src/fullCommonMain/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 actual fun LazyListScope.pluginsSettingsContent() { + item { + PluginsSettingsPageContent( + modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt index fa15ec007..f581b27c6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.ios.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.plugins import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.refTo +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi import com.nuvio.app.features.plugins.cryptointerop.CC_MD5 import com.nuvio.app.features.plugins.cryptointerop.CC_MD5_DIGEST_LENGTH import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1 @@ -21,7 +23,7 @@ private fun UByteArray.toHex(): String = joinToString(separator = "") { byte -> } @OptIn(ExperimentalForeignApi::class) -internal actual fun pluginDigestHex(algorithm: String, data: String): String { +internal fun pluginDigestHex(algorithm: String, data: String): String { val normalized = algorithm.uppercase() val input = data.encodeToByteArray() val output = UByteArray( @@ -45,7 +47,7 @@ internal actual fun pluginDigestHex(algorithm: String, data: String): String { } @OptIn(ExperimentalForeignApi::class) -internal actual fun pluginHmacHex(algorithm: String, key: String, data: String): String { +internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { val normalized = algorithm.uppercase() val keyBytes = key.encodeToByteArray() val input = data.encodeToByteArray() @@ -70,3 +72,34 @@ internal actual fun pluginHmacHex(algorithm: String, key: String, data: String): return output.toHex() } + +@OptIn(ExperimentalEncodingApi::class) +internal fun pluginBase64Encode(data: String): String = + Base64.encode(data.encodeToByteArray()) + +@OptIn(ExperimentalEncodingApi::class) +internal fun pluginBase64Decode(data: String): String { + val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + val decoded = Base64.decode(normalized) + return decoded.decodeToString() +} + +internal fun pluginUtf8ToHex(value: String): String = + value.encodeToByteArray().joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } + +internal fun pluginHexToUtf8(hex: String): String { + val normalized = hex.trim().lowercase() + .replace(" ", "") + .removePrefix("0x") + if (normalized.isEmpty()) return "" + + val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" + val out = ByteArray(evenHex.length / 2) + for (index in out.indices) { + val part = evenHex.substring(index * 2, index * 2 + 2) + out[index] = part.toInt(16).toByte() + } + return out.decodeToString() +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt new file mode 100644 index 000000000..d8b00417f --- /dev/null +++ b/composeApp/src/iosMain/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 providers." } + return manifest + } +} 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 index 6100a2262..a30ad428e 100644 --- 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 @@ -3,13 +3,13 @@ package com.nuvio.app.features.plugins import platform.Foundation.NSUserDefaults import platform.Foundation.timeIntervalSince1970 -internal actual object PluginStorage { +internal object PluginStorage { private const val pluginsStateKey = "plugins_state" - actual fun loadState(profileId: Int): String? = + fun loadState(profileId: Int): String? = NSUserDefaults.standardUserDefaults.stringForKey("${pluginsStateKey}_$profileId") - actual fun saveState(profileId: Int, payload: String) { + fun saveState(profileId: Int, payload: String) { NSUserDefaults.standardUserDefaults.setObject( payload, forKey = "${pluginsStateKey}_$profileId", @@ -17,7 +17,7 @@ internal actual object PluginStorage { } } -internal actual fun currentPluginPlatform(): String = "ios" +internal fun currentPluginPlatform(): String = "ios" -internal actual fun currentEpochMillis(): Long = +internal fun currentEpochMillis(): Long = (platform.Foundation.NSDate().timeIntervalSince1970 * 1000.0).toLong() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt new file mode 100644 index 000000000..aa721abdb --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -0,0 +1,566 @@ +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, +) + +actual 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()) + actual val uiState: StateFlow = _uiState.asStateFlow() + + private var initialized = false + private var pulledFromServer = false + private var currentProfileId = 1 + private val activeRefreshJobs = mutableMapOf() + + actual fun initialize() { + val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId) + val shouldRefreshStoredRepos = !initialized || currentProfileId != effectiveProfileId + ensureStateLoadedForProfile(effectiveProfileId) + if (!shouldRefreshStoredRepos) return + + _uiState.value.repositories.forEach { repo -> + refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false) + } + } + + actual fun onProfileChanged(profileId: Int) { + val effectiveProfileId = resolveEffectiveProfileId(profileId) + if (effectiveProfileId == currentProfileId && initialized) return + + cancelActiveRefreshes() + currentProfileId = effectiveProfileId + initialized = false + pulledFromServer = false + _uiState.value = PluginsUiState() + } + + actual fun clearLocalState() { + cancelActiveRefreshes() + currentProfileId = 1 + initialized = false + pulledFromServer = false + _uiState.value = PluginsUiState() + } + + actual suspend fun pullFromServer(profileId: Int) { + val effectiveProfileId = resolveEffectiveProfileId(profileId) + ensureStateLoadedForProfile(effectiveProfileId) + 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, + groupStreamsByRepository = _uiState.value.groupStreamsByRepository, + repositories = nextRepos, + scrapers = nextScrapers, + ) + persist() + + urls.forEach { url -> + refreshRepository(url, pushAfterRefresh = false) + } + + pulledFromServer = true + initialized = true + }.onFailure { error -> + log.e(error) { "pullFromServer failed" } + } + } + + actual 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") + } + } + + actual 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() + } + + actual fun refreshAll() { + initialize() + _uiState.value.repositories.forEach { repo -> + refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false) + } + } + + actual fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean) { + refreshRepositoryInternal(manifestUrl, pushAfterRefresh, ensureInitialized = true) + } + + private fun refreshRepositoryInternal( + manifestUrl: String, + pushAfterRefresh: Boolean, + ensureInitialized: Boolean, + ) { + if (ensureInitialized) { + 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 + } + + actual 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() + } + + actual fun setPluginsEnabled(enabled: Boolean) { + initialize() + _uiState.update { it.copy(pluginsEnabled = enabled) } + persist() + } + + actual fun setGroupStreamsByRepository(enabled: Boolean) { + initialize() + _uiState.update { it.copy(groupStreamsByRepository = enabled) } + persist() + } + + actual fun getEnabledScrapersForType(type: String): List { + initialize() + if (!_uiState.value.pluginsEnabled) return emptyList() + return _uiState.value.scrapers.filter { scraper -> + scraper.enabled && scraper.supportsType(type) + } + } + + actual suspend fun testScraper(scraperId: String): Result> { + initialize() + val scraper = _uiState.value.scrapers.find { it.id == scraperId } + ?: return Result.failure(IllegalArgumentException("Provider 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, + ) + } + + actual 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, + groupStreamsByRepository = state.groupStreamsByRepository, + 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 ensureStateLoadedForProfile(profileId: Int) { + if (initialized && currentProfileId == profileId) return + + if (currentProfileId != profileId) { + cancelActiveRefreshes() + pulledFromServer = false + } + + currentProfileId = profileId + _uiState.value = loadStateAsUiState(profileId) + initialized = true + } + + private fun loadStateAsUiState(profileId: Int): PluginsUiState { + val stored = loadStoredState(profileId) + return PluginsUiState( + pluginsEnabled = stored?.pluginsEnabled ?: true, + groupStreamsByRepository = stored?.groupStreamsByRepository ?: false, + 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(), + ) + } + + 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/iosMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt new file mode 100644 index 000000000..641d56ba1 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -0,0 +1,987 @@ +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("__crypto_digest_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val data = args.getOrNull(1)?.toString() ?: "" + runCatching { + pluginDigestHex(algorithm, data) + }.getOrDefault("") + } + + function("__crypto_hmac_hex") { args -> + val algorithm = args.getOrNull(0)?.toString() ?: "SHA256" + val key = args.getOrNull(1)?.toString() ?: "" + val data = args.getOrNull(2)?.toString() ?: "" + runCatching { + pluginHmacHex(algorithm, key, data) + }.getOrDefault("") + } + + function("__crypto_base64_encode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Encode(data) + }.getOrDefault("") + } + + function("__crypto_base64_decode") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginBase64Decode(data) + }.getOrDefault("") + } + + function("__crypto_utf8_to_hex") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginUtf8ToHex(data) + }.getOrDefault("") + } + + function("__crypto_hex_to_utf8") { args -> + val data = args.getOrNull(0)?.toString() ?: "" + runCatching { + pluginHexToUtf8(data) + }.getOrDefault("") + } + + 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; + }; + + function __hexToWords(hex) { + var words = []; + for (var i = 0; i < hex.length; i += 8) { + var chunk = hex.substring(i, i + 8); + while (chunk.length < 8) chunk += '0'; + words.push(parseInt(chunk, 16) | 0); + } + return words; + } + + function __wordsToHex(words, sigBytes) { + var hex = ''; + for (var i = 0; i < sigBytes; i++) { + var word = words[i >>> 2] || 0; + var byte = (word >>> (24 - (i % 4) * 8)) & 0xff; + var part = byte.toString(16); + if (part.length < 2) part = '0' + part; + hex += part; + } + return hex; + } + + function __wordArrayToHex(value) { + if (!value) return ''; + if (typeof value.__hex === 'string') return value.__hex.toLowerCase(); + if (Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return __wordsToHex(value.words, value.sigBytes); + } + return __crypto_utf8_to_hex(String(value)); + } + + function __buildWordArray(hex, utf8Override) { + var normalizedHex = (hex || '').toLowerCase(); + if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex; + var wordArray = { + __hex: normalizedHex, + __utf8: utf8Override !== undefined ? utf8Override : __crypto_hex_to_utf8(normalizedHex), + sigBytes: normalizedHex.length / 2, + words: __hexToWords(normalizedHex), + toString: function(encoder) { + if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex; + if (encoder === CryptoJS.enc.Utf8) return this.__utf8; + if (encoder === CryptoJS.enc.Base64) return __crypto_base64_encode(this.__utf8); + return this.__hex; + }, + clamp: function() { + return this; + }, + concat: function(other) { + var otherHex = __wordArrayToHex(other); + this.__hex += otherHex; + this.__utf8 = __crypto_hex_to_utf8(this.__hex); + this.sigBytes = this.__hex.length / 2; + this.words = __hexToWords(this.__hex); + return this; + } + }; + return wordArray; + } + + function __wordArrayFromHex(hex) { + return __buildWordArray(hex, undefined); + } + + function __wordArrayFromUtf8(text) { + var utf8 = text == null ? '' : String(text); + return __buildWordArray(__crypto_utf8_to_hex(utf8), utf8); + } + + function __wordArrayFromBase64(base64) { + return __wordArrayFromUtf8(__crypto_base64_decode(base64 || '')); + } + + function __normalizeWordArrayInput(value) { + if (value && typeof value === 'object' && typeof value.__utf8 === 'string') { + return value.__utf8; + } + if (value && typeof value === 'object' && typeof value.__hex === 'string') { + return __crypto_hex_to_utf8(value.__hex); + } + if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') { + return __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes)); + } + if (value == null) return ''; + return String(value); + } + + function __cryptoHashWordArray(algorithm, message) { + var utf8 = __normalizeWordArrayInput(message); + var hex = __crypto_digest_hex(algorithm, utf8); + return __wordArrayFromHex(hex); + } + + function __cryptoHmacWordArray(algorithm, message, key) { + var utf8Message = __normalizeWordArrayInput(message); + var utf8Key = __normalizeWordArrayInput(key); + var hex = __crypto_hmac_hex(algorithm, utf8Key, utf8Message); + return __wordArrayFromHex(hex); + } + + var CryptoJS = { + enc: { + Hex: { + stringify: function(wordArray) { + return __wordArrayToHex(wordArray); + }, + parse: function(hexStr) { + return __wordArrayFromHex(hexStr || ''); + } + }, + Utf8: { + stringify: function(wordArray) { + if (wordArray && typeof wordArray.__utf8 === 'string') return wordArray.__utf8; + if (wordArray && typeof wordArray.__hex === 'string') return __crypto_hex_to_utf8(wordArray.__hex); + return __normalizeWordArrayInput(wordArray); + }, + parse: function(text) { + return __wordArrayFromUtf8(text); + } + }, + Base64: { + stringify: function(wordArray) { + if (wordArray && typeof wordArray.__utf8 === 'string') { + return __crypto_base64_encode(wordArray.__utf8); + } + return __crypto_base64_encode(__normalizeWordArrayInput(wordArray)); + }, + parse: function(base64) { + return __wordArrayFromBase64(base64); + } + } + }, + MD5: function(message) { return __cryptoHashWordArray('MD5', message); }, + SHA1: function(message) { return __cryptoHashWordArray('SHA1', message); }, + SHA256: function(message) { return __cryptoHashWordArray('SHA256', message); }, + SHA512: function(message) { return __cryptoHashWordArray('SHA512', message); }, + HmacMD5: function(message, key) { return __cryptoHmacWordArray('MD5', message, key); }, + HmacSHA1: function(message, key) { return __cryptoHmacWordArray('SHA1', message, key); }, + HmacSHA256: function(message, key) { return __cryptoHmacWordArray('SHA256', message, key); }, + HmacSHA512: function(message, key) { return __cryptoHmacWordArray('SHA512', message, key); } + }; + globalThis.CryptoJS = CryptoJS; + + 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; + } + if (moduleName === 'crypto-js') { + return CryptoJS; + } + 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/iosMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt new file mode 100644 index 000000000..4838ab51c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt @@ -0,0 +1,434 @@ +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 repositoryNameByUrl = remember(sortedRepos) { + sortedRepos.associate { it.manifestUrl to it.name } + } + val sortedScrapers = remember(uiState.scrapers, repositoryNameByUrl) { + uiState.scrapers.sortedWith( + compareBy( + { repositoryNameByUrl[it.repositoryUrl]?.lowercase() ?: it.repositoryUrl.lowercase() }, + { 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} providers") + 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, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Enable plugin providers globally", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "Use plugin providers during stream discovery.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Switch( + checked = uiState.pluginsEnabled, + onCheckedChange = { PluginRepository.setPluginsEnabled(it) }, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Group plugin providers by repository", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = "In Streams, show one provider per repository instead of one per source.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Switch( + checked = uiState.groupStreamsByRepository, + onCheckedChange = { PluginRepository.setGroupStreamsByRepository(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 install provider plugins for 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} providers") + 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("PROVIDERS") + if (sortedScrapers.isEmpty()) { + NuvioSurfaceCard { + Text( + text = "No providers available yet.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + sortedScrapers.forEach { scraper -> + val scraperResults = testResults[scraper.id] + val isTestingThisScraper = testingScraperId == scraper.id + val repositoryName = repositoryNameByUrl[scraper.repositoryUrl] + ?: scraper.repositoryUrl.fallbackRepositoryLabel() + + 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 = repositoryName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + 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}") + if (!scraper.manifestEnabled) { + NuvioInfoBadge(text = "Disabled by repo") + } + } + + Spacer(modifier = Modifier.height(12.dp)) + NuvioPrimaryButton( + text = if (isTestingThisScraper) "Testing..." else "Test Provider", + 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 ?: "Provider 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)) + } + } + } + } + } + } +} + +private fun String.fallbackRepositoryLabel(): String { + val withoutQuery = substringBefore("?") + val withoutManifest = withoutQuery.removeSuffix("/manifest.json") + val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') + return host.ifBlank { + withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt new file mode 100644 index 000000000..d7e2cb5ad --- /dev/null +++ b/composeApp/src/iosMain/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 actual fun LazyListScope.pluginsSettingsContent() { + item { + PluginsSettingsPageContent( + modifier = Modifier.fillMaxWidth(), + ) + } +}