diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index a5715293..c1091b60 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -170,12 +170,12 @@ kotlin { implementation(libs.supabase.postgrest) implementation(libs.supabase.auth) implementation(libs.supabase.functions) - implementation(libs.quickjs.kt) - implementation(libs.ksoup) implementation(libs.reorderable) } iosMain.dependencies { implementation(libs.ktor.client.darwin) + implementation(libs.quickjs.kt) + implementation(libs.ksoup) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -183,6 +183,13 @@ kotlin { } } +afterEvaluate { + dependencies { + add("androidFullImplementation", libs.quickjs.kt) + add("androidFullImplementation", libs.ksoup) + } +} + dependencies { debugImplementation(libs.compose.uiTooling) } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt deleted file mode 100644 index 71e985be..00000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.nuvio.app.features.plugins - -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi - -internal expect fun pluginDigestHex(algorithm: String, data: String): String - -internal expect fun pluginHmacHex(algorithm: String, key: String, data: String): String - -@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/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt deleted file mode 100644 index d8b00417..00000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginManifestParser.kt +++ /dev/null @@ -1,17 +0,0 @@ -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/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt deleted file mode 100644 index 2b6c3fa1..00000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.nuvio.app.features.plugins - -internal expect object PluginStorage { - fun loadState(profileId: Int): String? - fun saveState(profileId: Int, payload: String) -} - -internal expect fun currentPluginPlatform(): String - -internal expect fun currentEpochMillis(): Long diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt deleted file mode 100644 index aa721abd..00000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ /dev/null @@ -1,566 +0,0 @@ -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 deleted file mode 100644 index 641d56ba..00000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ /dev/null @@ -1,987 +0,0 @@ -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 deleted file mode 100644 index 4838ab51..00000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt +++ /dev/null @@ -1,434 +0,0 @@ -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 deleted file mode 100644 index d7e2cb5a..00000000 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/settings/PluginsSettingsPage.kt +++ /dev/null @@ -1,14 +0,0 @@ -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(), - ) - } -}