Merge branch 'cmp' into cmp-rewrite

This commit is contained in:
tapframe 2026-06-22 19:06:31 +05:30
commit 82999686cc
39 changed files with 3308 additions and 1079 deletions

View file

@ -11,13 +11,18 @@ internal object PlatformPlaybackDataSourceFactory {
defaultRequestHeaders: Map<String, String>,
defaultResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
): DataSource.Factory {
val networkFactory: DataSource.Factory = if (useYoutubeChunkedPlayback) {
YoutubeChunkedDataSourceFactory(defaultRequestHeaders = defaultRequestHeaders)
} else {
PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders)
}
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, networkFactory)
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
upstreamFactory = networkFactory,
externalSubtitles = externalSubtitles
)
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory)
return if (defaultResponseHeaders.isEmpty()) {
baseFactory
} else {

View file

@ -1,42 +1,249 @@
package com.nuvio.app.features.plugins
import java.security.KeyFactory
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import javax.crypto.Cipher
import javax.crypto.Mac
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
private val secureRandom = SecureRandom()
internal fun pluginGetRandomValues(length: Int): ByteArray {
require(length >= 0) { "Random byte length must be non-negative" }
val bytes = ByteArray(length)
secureRandom.nextBytes(bytes)
return bytes
}
internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray {
return MessageDigest.getInstance(normalizeDigestAlgorithm(algorithm)).digest(data)
}
internal fun pluginPbkdf2(
password: ByteArray,
salt: ByteArray,
iterations: Int,
keySizeBits: Int,
algorithm: String,
): ByteArray {
require(iterations > 0) { "PBKDF2 iterations must be positive" }
require(keySizeBits > 0 && keySizeBits % 8 == 0) { "PBKDF2 key size must be a positive byte-aligned bit length" }
val prfAlgo = normalizeHmacAlgorithm(algorithm)
val mac = Mac.getInstance(prfAlgo)
mac.init(SecretKeySpec(password, prfAlgo))
val hLen = mac.macLength
val dkLen = keySizeBits / 8
val dk = ByteArray(dkLen)
val blocks = (dkLen + hLen - 1) / hLen
val u = ByteArray(hLen)
val t = ByteArray(hLen)
val blockIndexBytes = ByteArray(4)
for (i in 1..blocks) {
mac.reset()
mac.update(salt)
blockIndexBytes[0] = (i ushr 24).toByte()
blockIndexBytes[1] = (i ushr 16).toByte()
blockIndexBytes[2] = (i ushr 8).toByte()
blockIndexBytes[3] = i.toByte()
mac.update(blockIndexBytes)
val u1 = mac.doFinal()
u1.copyInto(t)
u1.copyInto(u)
for (j in 2..iterations) {
mac.reset()
val uj = mac.doFinal(u)
uj.copyInto(u)
for (k in 0 until hLen) {
t[k] = (t[k].toInt() xor uj[k].toInt()).toByte()
}
}
val offset = (i - 1) * hLen
val len = minOf(hLen, dkLen - offset)
t.copyInto(dk, destinationOffset = offset, startIndex = 0, endIndex = len)
}
return dk
}
internal fun pluginAesEncrypt(
mode: String,
key: ByteArray,
iv: ByteArray,
data: ByteArray,
): ByteArray {
val normalizedMode = normalizeAesTransformation(mode)
requireValidAesKey(key)
if (!normalizedMode.contains("ECB")) {
require(iv.isNotEmpty()) { "AES mode $mode requires an IV" }
}
val cipher = Cipher.getInstance(normalizedMode)
val keySpec = SecretKeySpec(key, "AES")
if (normalizedMode.contains("ECB")) {
cipher.init(Cipher.ENCRYPT_MODE, keySpec)
} else if (normalizedMode.contains("GCM")) {
val gcmSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, gcmSpec)
} else {
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec)
}
return cipher.doFinal(data)
}
internal fun pluginAesDecrypt(
mode: String,
key: ByteArray,
iv: ByteArray,
data: ByteArray,
): ByteArray {
val normalizedMode = normalizeAesTransformation(mode)
requireValidAesKey(key)
if (!normalizedMode.contains("ECB")) {
require(iv.isNotEmpty()) { "AES mode $mode requires an IV" }
}
val cipher = Cipher.getInstance(normalizedMode)
val keySpec = SecretKeySpec(key, "AES")
if (normalizedMode.contains("ECB")) {
cipher.init(Cipher.DECRYPT_MODE, keySpec)
} else if (normalizedMode.contains("GCM")) {
val gcmSpec = GCMParameterSpec(128, iv)
cipher.init(Cipher.DECRYPT_MODE, keySpec, gcmSpec)
} else {
val ivSpec = IvParameterSpec(iv)
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
}
return cipher.doFinal(data)
}
internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray {
val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) {
"RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA"
"ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA"
else -> "RSA" to "SHA256withRSA"
}
val factory = KeyFactory.getInstance(keyAlgo)
val privKey = factory.generatePrivate(PKCS8EncodedKeySpec(privateKey))
val sig = Signature.getInstance(sigAlgo)
sig.initSign(privKey)
sig.update(data)
return sig.sign()
}
internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean {
val (keyAlgo, sigAlgo) = when (algorithm.uppercase()) {
"RSASSA-PKCS1-V1_5-SHA256", "RSASSA-PKCS1-V1_5" -> "RSA" to "SHA256withRSA"
"ECDSA-SHA256", "ECDSA" -> "EC" to "SHA256withECDSA"
else -> "RSA" to "SHA256withRSA"
}
val factory = KeyFactory.getInstance(keyAlgo)
val pubKey = factory.generatePublic(X509EncodedKeySpec(publicKey))
val sig = Signature.getInstance(sigAlgo)
sig.initVerify(pubKey)
sig.update(data)
return sig.verify(signature)
}
internal fun pluginDigestHex(algorithm: String, data: String): String {
val normalized = algorithm.uppercase()
val digest = MessageDigest.getInstance(normalized).digest(data.encodeToByteArray())
val digest = pluginDigest(algorithm, data.encodeToByteArray())
return digest.joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
}
internal fun pluginHmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray {
val normalized = normalizeHmacAlgorithm(algorithm)
val mac = Mac.getInstance(normalized)
mac.init(SecretKeySpec(key, normalized))
return mac.doFinal(data)
}
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())
val digest = pluginHmac(algorithm, key.encodeToByteArray(), data.encodeToByteArray())
return digest.joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
}
private fun normalizeDigestAlgorithm(algorithm: String): String {
return when (algorithm.normalizedAlgorithmToken()) {
"MD5" -> "MD5"
"SHA1" -> "SHA-1"
"SHA256" -> "SHA-256"
"SHA384" -> "SHA-384"
"SHA512" -> "SHA-512"
else -> error("Unsupported digest algorithm: $algorithm")
}
}
private fun normalizeHmacAlgorithm(algorithm: String): String {
return when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) {
"MD5" -> "HmacMD5"
"SHA1" -> "HmacSHA1"
"SHA256" -> "HmacSHA256"
"SHA384" -> "HmacSHA384"
"SHA512" -> "HmacSHA512"
else -> error("Unsupported HMAC algorithm: $algorithm")
}
}
private fun normalizeAesTransformation(mode: String): String {
val normalized = mode.normalizedAlgorithmToken()
val noPadding = normalized.contains("NOPADDING")
val padding = if (noPadding) "NoPadding" else "PKCS5Padding"
return when {
normalized.contains("GCM") -> "AES/GCM/NoPadding"
normalized.contains("ECB") -> "AES/ECB/$padding"
normalized.contains("CBC") -> "AES/CBC/$padding"
else -> "AES/CBC/$padding"
}
}
private fun requireValidAesKey(key: ByteArray) {
require(key.size == 16 || key.size == 24 || key.size == 32) {
"AES key must be 16, 24, or 32 bytes"
}
}
private fun String.normalizedAlgorithmToken(): String =
uppercase()
.replace("-", "")
.replace("_", "")
.replace("/", "")
.replace(" ", "")
@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(" ", "")
var normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "")
// Robust URL-safe base64 decoding fallback
normalized = normalized.replace("-", "+").replace("_", "/")
val padNeeded = (4 - (normalized.length % 4)) % 4
if (padNeeded > 0) {
normalized += "=".repeat(padNeeded)
}
val decoded = Base64.decode(normalized)
return decoded.decodeToString()
}
@ -46,11 +253,11 @@ internal fun pluginUtf8ToHex(value: String): String =
byte.toUByte().toString(16).padStart(2, '0')
}
internal fun pluginHexToUtf8(hex: String): String {
internal fun pluginHexToByteArray(hex: String): ByteArray {
val normalized = hex.trim().lowercase()
.replace(" ", "")
.removePrefix("0x")
if (normalized.isEmpty()) return ""
if (normalized.isEmpty()) return ByteArray(0)
val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized"
val out = ByteArray(evenHex.length / 2)
@ -58,5 +265,9 @@ internal fun pluginHexToUtf8(hex: String): String {
val part = evenHex.substring(index * 2, index * 2 + 2)
out[index] = part.toInt(16).toByte()
}
return out.decodeToString()
return out
}
internal fun pluginHexToUtf8(hex: String): String {
return pluginHexToByteArray(hex).decodeToString()
}

View file

@ -22,6 +22,16 @@ internal object PluginStorage {
?.putString("${pluginsStateKey}_$profileId", payload)
?.apply()
}
fun loadScraperSettings(scraperId: String): String? =
preferences?.getString("settings_${scraperId}", null)
fun saveScraperSettings(scraperId: String, payload: String) {
preferences
?.edit()
?.putString("settings_${scraperId}", payload)
?.apply()
}
}
internal fun currentPluginPlatform(): String = "android"

View file

@ -39,6 +39,10 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.TransferListener
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
@ -46,6 +50,7 @@ import androidx.media3.exoplayer.ForwardingRenderer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MergingMediaSource
import com.nuvio.app.features.trailer.YoutubeChunkedDataSourceFactory
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
@ -76,6 +81,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
streamType: String?,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
@ -127,12 +133,28 @@ actual fun PlatformPlayerSurface(
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
val initialMediaItem = remember(playerSourceKey) {
val initialMediaItem = remember(playerSourceKey, externalSubtitles) {
val subtitleConfigs = externalSubtitles.mapNotNull { subtitle ->
val mimeType = resolveSubtitleMimeType(subtitle.url, subtitle.headers)
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.url))
.setMimeType(mimeType)
.setLanguage(subtitle.language)
.setLabel(subtitle.name ?: subtitle.language)
.setRoleFlags(C.ROLE_FLAG_SUBTITLE)
.build()
}
playbackMediaItemFromUrl(
url = sourceUrl,
responseHeaders = sanitizedSourceResponseHeaders,
streamType = normalizedStreamType,
)
).buildUpon()
.setMediaId(sourceUrl)
.apply {
if (subtitleConfigs.isNotEmpty()) {
setSubtitleConfigurations(subtitleConfigs)
}
}
.build()
}
var resolvedMediaItem by remember(playerSourceKey) { mutableStateOf(initialMediaItem) }
@ -148,12 +170,14 @@ actual fun PlatformPlayerSurface(
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback,
externalSubtitles,
) {
PlatformPlaybackDataSourceFactory.create(
context = context,
defaultRequestHeaders = sanitizedSourceHeaders,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
externalSubtitles = externalSubtitles,
)
}
@ -309,6 +333,21 @@ actual fun PlatformPlayerSurface(
resolvedMediaItem = MediaItem.Builder()
.setUri(sourceUrl)
.setMimeType(probedMime)
.setMediaId(sourceUrl)
.apply {
val subtitleConfigs = externalSubtitles.mapNotNull { subtitle ->
val mimeType = resolveSubtitleMimeType(subtitle.url, subtitle.headers)
MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.url))
.setMimeType(mimeType)
.setLanguage(subtitle.language)
.setLabel(subtitle.name ?: subtitle.language)
.setRoleFlags(C.ROLE_FLAG_SUBTITLE)
.build()
}
if (subtitleConfigs.isNotEmpty()) {
setSubtitleConfigurations(subtitleConfigs)
}
}
.build()
latestOnError.value(null)
return@launch
@ -1021,15 +1060,15 @@ private class SubtitleOffsetRenderer(
}
}
private fun resolveSubtitleMimeType(url: String): String {
probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) ->
private fun resolveSubtitleMimeType(url: String, headers: Map<String, String>? = null): String {
probeSubtitleHeaders(url, headers)?.let { (contentType, contentDisposition) ->
mapSubtitleMime(contentType)?.let { return it }
filenameFromContentDisposition(contentDisposition)?.let(::guessSubtitleMime)?.let { return it }
}
return guessSubtitleMime(url)
}
private fun probeSubtitleHeaders(url: String): Pair<String?, String?>? {
private fun probeSubtitleHeaders(url: String, headers: Map<String, String>? = null): Pair<String?, String?>? {
val methods = listOf("HEAD", "GET")
methods.forEach { method ->
runCatching {
@ -1039,6 +1078,9 @@ private fun probeSubtitleHeaders(url: String): Pair<String?, String?>? {
readTimeout = 5_000
instanceFollowRedirects = true
setRequestProperty("Accept", "*/*")
headers?.forEach { (key, value) ->
setRequestProperty(key, value)
}
}
try {
connection.responseCode
@ -1093,3 +1135,50 @@ private fun guessSubtitleMime(url: String): String {
else -> MimeTypes.TEXT_VTT
}
}
internal class SubtitleRequestHeaderDataSourceFactory(
private val upstreamFactory: DataSource.Factory,
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
) : DataSource.Factory {
override fun createDataSource(): DataSource =
SubtitleRequestHeaderDataSource(
upstream = upstreamFactory.createDataSource(),
externalSubtitles = externalSubtitles,
)
}
internal class SubtitleRequestHeaderDataSource(
private val upstream: DataSource,
private val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
) : DataSource {
override fun addTransferListener(transferListener: TransferListener) {
upstream.addTransferListener(transferListener)
}
override fun open(dataSpec: DataSpec): Long {
val url = dataSpec.uri.toString()
val subtitle = externalSubtitles.find { it.url == url }
val headers = subtitle?.headers
return if (headers.isNullOrEmpty()) {
upstream.open(dataSpec)
} else {
val mergedHeaders = dataSpec.httpRequestHeaders.toMutableMap()
headers.forEach { (key, value) ->
mergedHeaders[key] = value
}
upstream.open(dataSpec.buildUpon().setHttpRequestHeaders(mergedHeaders).build())
}
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int =
upstream.read(buffer, offset, length)
override fun getUri(): Uri? = upstream.uri
override fun getResponseHeaders(): Map<String, List<String>> = upstream.responseHeaders
override fun close() {
upstream.close()
}
}

View file

@ -10,9 +10,14 @@ internal object PlatformPlaybackDataSourceFactory {
defaultRequestHeaders: Map<String, String>,
defaultResponseHeaders: Map<String, String>,
useYoutubeChunkedPlayback: Boolean,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
): DataSource.Factory {
val httpFactory = PlayerPlaybackNetworking.createHttpDataSourceFactory(defaultRequestHeaders)
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, httpFactory)
val subtitleHeaderFactory = SubtitleRequestHeaderDataSourceFactory(
upstreamFactory = httpFactory,
externalSubtitles = externalSubtitles
)
val baseFactory: DataSource.Factory = DefaultDataSource.Factory(context, subtitleHeaderFactory)
return if (defaultResponseHeaders.isEmpty()) {
baseFactory
} else {

View file

@ -1201,6 +1201,7 @@ private fun MainAppContent(
sourceUrl = localSourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
externalSubtitles = emptyList(),
logo = logo,
poster = poster,
background = background,
@ -2027,6 +2028,7 @@ private fun MainAppContent(
sourceUrl = cached.url,
sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders),
externalSubtitles = emptyList(),
streamType = cached.streamType,
logo = launch.logo,
poster = launch.poster,
@ -2163,6 +2165,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
externalSubtitles = stream.externalSubtitles,
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
@ -2290,6 +2293,7 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request),
sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response),
externalSubtitles = stream.externalSubtitles,
streamType = stream.streamType,
logo = launch.logo,
poster = launch.poster,
@ -2450,6 +2454,7 @@ private fun MainAppContent(
sourceAudioUrl = launch.sourceAudioUrl,
sourceHeaders = launch.sourceHeaders,
sourceResponseHeaders = launch.sourceResponseHeaders,
externalSubtitles = launch.externalSubtitles,
streamType = launch.streamType,
logo = launch.logo,
poster = launch.poster,
@ -2613,6 +2618,8 @@ private fun MainAppContent(
sourceUrl = sourceUrl,
sourceHeaders = emptyMap(),
sourceResponseHeaders = emptyMap(),
externalSubtitles = emptyList(),
streamType = null,
logo = item.logo,
poster = item.poster,
background = item.background,

View file

@ -58,6 +58,7 @@ expect fun PlatformPlayerSurface(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
streamType: String? = null,
useYoutubeChunkedPlayback: Boolean = false,
modifier: Modifier = Modifier,

View file

@ -26,6 +26,7 @@ data class PlayerLaunch(
val sourceAudioUrl: String? = null,
val sourceHeaders: Map<String, String> = emptyMap(),
val sourceResponseHeaders: Map<String, String> = emptyMap(),
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
val streamType: String? = null,
val logo: String? = null,
val poster: String? = null,

View file

@ -11,6 +11,7 @@ fun PlayerScreen(
sourceAudioUrl: String? = null,
sourceHeaders: Map<String, String> = emptyMap(),
sourceResponseHeaders: Map<String, String> = emptyMap(),
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
streamType: String? = null,
providerName: String,
streamTitle: String,
@ -48,6 +49,7 @@ fun PlayerScreen(
sourceAudioUrl = sourceAudioUrl,
sourceHeaders = sourceHeaders,
sourceResponseHeaders = sourceResponseHeaders,
externalSubtitles = externalSubtitles,
streamType = streamType,
providerName = providerName,
streamTitle = streamTitle,

View file

@ -30,6 +30,7 @@ internal data class PlayerScreenArgs(
val parentMetaId: String,
val parentMetaType: String,
val providerAddonId: String?,
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> = emptyList(),
val torrentInfoHash: String?,
val torrentFileIdx: Int?,
val torrentFilename: String?,

View file

@ -57,6 +57,7 @@ internal class PlayerScreenRuntime(
val torrentTrackers: List<String> get() = args.torrentTrackers
val initialPositionMs: Long get() = args.initialPositionMs
val initialProgressFraction: Float? get() = args.initialProgressFraction
val externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle> get() = args.externalSubtitles
val isSeries: Boolean get() = parentMetaType == "series"
lateinit var scope: CoroutineScope

View file

@ -121,6 +121,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
sourceAudioUrl = activeSourceAudioUrl,
sourceHeaders = activeSourceHeaders,
sourceResponseHeaders = activeSourceResponseHeaders,
externalSubtitles = externalSubtitles,
streamType = activeStreamType,
modifier = Modifier.fillMaxSize(),
playWhenReady = shouldPlay,

View file

@ -480,3 +480,32 @@ object PlayerStreamsRepository {
setJob(job)
}
}
private data class PlayerInstalledStreamAddonTarget(
val addonName: String,
val addonId: String,
val manifest: com.nuvio.app.features.addons.AddonManifest,
)
private fun StreamsUiState.streamDiagnostics(): String {
val streamCount = groups.sumOf { it.streams.size }
val loadingCount = groups.count { it.isLoading }
val errorCount = groups.count { !it.error.isNullOrBlank() }
val sampleGroups = groups.take(4).joinToString(prefix = "[", postfix = "]") { group ->
buildString {
append(group.addonName)
append(':')
append(group.streams.size)
if (group.isLoading) append(":loading")
if (!group.error.isNullOrBlank()) append(":error")
}
}
val suffix = if (groups.size > 4) "+${groups.size - 4}" else ""
return "groups=${groups.size} streams=$streamCount isAnyLoading=$isAnyLoading " +
"loadingGroups=$loadingCount errorGroups=$errorCount empty=${emptyStateReason ?: "none"} " +
"sample=$sampleGroups$suffix"
}
private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String =
"addon:$manifestId:$manifestUrl"

View file

@ -11,10 +11,39 @@ object PlayerSubtitleCueParser {
.trim()
if (normalized.isBlank()) return emptyList()
return if (sourceUrl?.endsWith(".vtt", ignoreCase = true) == true || normalized.startsWith("WEBVTT")) {
parseWebVtt(normalized)
} else {
parseSrt(normalized)
return when (detectSubtitleFormat(sourceUrl, normalized)) {
SubtitleFormatHint.WebVtt -> parseWebVtt(normalized)
SubtitleFormatHint.Ass -> parseAss(normalized)
SubtitleFormatHint.Ttml -> parseTtml(normalized)
SubtitleFormatHint.Srt -> parseSrt(normalized)
}
}
private enum class SubtitleFormatHint {
Srt,
WebVtt,
Ass,
Ttml,
}
private fun detectSubtitleFormat(sourceUrl: String?, text: String): SubtitleFormatHint {
val sourcePath = sourceUrl
?.substringBefore('?')
?.substringBefore('#')
?.lowercase()
.orEmpty()
val sample = text.take(4_096).lowercase()
return when {
sourcePath.endsWith(".vtt") || sourcePath.endsWith(".webvtt") || text.startsWith("WEBVTT") ->
SubtitleFormatHint.WebVtt
sourcePath.endsWith(".ass") || sourcePath.endsWith(".ssa") ||
(sample.contains("[events]") && sample.contains("dialogue:")) ->
SubtitleFormatHint.Ass
sourcePath.endsWith(".ttml") || sourcePath.endsWith(".dfxp") || sourcePath.endsWith(".xml") ||
Regex("""<tt[\s>]""", RegexOption.IGNORE_CASE).containsMatchIn(text.take(512)) ->
SubtitleFormatHint.Ttml
else -> SubtitleFormatHint.Srt
}
}
@ -53,6 +82,69 @@ object PlayerSubtitleCueParser {
}
.sortedBy { it.startTimeMs }
private fun parseAss(text: String): List<SubtitleSyncCue> {
var inEventsSection = false
var formatFields: List<String>? = null
return text.lines()
.mapNotNull { rawLine ->
val line = rawLine.trim()
when {
line.equals("[Events]", ignoreCase = true) -> {
inEventsSection = true
null
}
line.startsWith("[") && line.endsWith("]") -> {
inEventsSection = false
null
}
inEventsSection && line.startsWith("Format:", ignoreCase = true) -> {
formatFields = line.substringAfter(':')
.split(',')
.map { it.trim() }
null
}
inEventsSection && line.startsWith("Dialogue:", ignoreCase = true) ->
parseAssDialogue(line.substringAfter(':'), formatFields)
else -> null
}
}
.sortedBy { it.startTimeMs }
}
private fun parseAssDialogue(payload: String, formatFields: List<String>?): SubtitleSyncCue? {
val fields = formatFields.orEmpty()
val parts = payload
.split(',', limit = fields.ifEmpty { defaultAssFormatFields }.size)
.map { it.trim() }
val startIndex = fields.indexOfField("Start").takeIf { it >= 0 } ?: 1
val textIndex = fields.indexOfField("Text").takeIf { it >= 0 } ?: 9
if (parts.size <= startIndex || parts.size <= textIndex) return null
val start = parseTimestamp(parts[startIndex]) ?: return null
val body = parts[textIndex]
.cleanAssCueText()
.cleanSubtitleCueText()
return if (body.isBlank()) null else SubtitleSyncCue(start, body)
}
private fun parseTtml(text: String): List<SubtitleSyncCue> =
Regex("""<p\b([^>]*)>(.*?)</p>""", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
.findAll(text)
.mapNotNull { match ->
val attrs = match.groupValues[1]
val startRaw = attrs.attributeValue("begin")
?: attrs.attributeValue("start")
?: return@mapNotNull null
val start = parseTtmlTimestamp(startRaw) ?: return@mapNotNull null
val body = match.groupValues[2]
.replace(Regex("""<br\s*/?>""", RegexOption.IGNORE_CASE), " ")
.cleanSubtitleCueText()
if (body.isBlank()) null else SubtitleSyncCue(start, body)
}
.sortedBy { it.startTimeMs }
.toList()
private fun parseCueStart(timingLine: String): Long? {
val startPart = timingLine.substringBefore("-->").trim()
return parseTimestamp(startPart)
@ -76,12 +168,75 @@ object PlayerSubtitleCueParser {
return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + millis)
}
private fun parseTtmlTimestamp(raw: String): Long? {
val cleaned = raw.trim().substringBefore(' ')
if (cleaned.isBlank()) return null
parseClockTimeWithFrames(cleaned)?.let { return it }
parseTimestamp(cleaned)?.let { return it }
val match = Regex("""^([0-9]+(?:\.[0-9]+)?)(ms|h|m|s)$""", RegexOption.IGNORE_CASE)
.matchEntire(cleaned)
?: return null
val value = match.groupValues[1].toDoubleOrNull() ?: return null
val multiplier = when (match.groupValues[2].lowercase()) {
"h" -> 3_600_000.0
"m" -> 60_000.0
"s" -> 1_000.0
"ms" -> 1.0
else -> return null
}
return max(0L, (value * multiplier).toLong())
}
private fun parseClockTimeWithFrames(raw: String): Long? {
val parts = raw.split(':')
if (parts.size != 4) return null
val hours = parts[0].toLongOrNull() ?: return null
val minutes = parts[1].toLongOrNull() ?: return null
val seconds = parts[2].toLongOrNull() ?: return null
val frames = parts[3].substringBefore('.').toLongOrNull() ?: return null
return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + frames * 1_000L / 30L)
}
private val defaultAssFormatFields = listOf(
"Layer",
"Start",
"End",
"Style",
"Name",
"MarginL",
"MarginR",
"MarginV",
"Effect",
"Text",
)
private fun List<String>.indexOfField(name: String): Int =
indexOfFirst { it.equals(name, ignoreCase = true) }
private fun String.attributeValue(name: String): String? =
Regex("""\b${Regex.escape(name)}\s*=\s*["']([^"']+)["']""", RegexOption.IGNORE_CASE)
.find(this)
?.groupValues
?.getOrNull(1)
?.takeIf { it.isNotBlank() }
private fun String.cleanAssCueText(): String =
replace(Regex("""\{[^}]*}"""), "")
.replace("\\N", " ")
.replace("\\n", " ")
.replace("\\h", " ")
private fun String.cleanSubtitleCueText(): String =
replace(Regex("<[^>]+>"), "")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'")
.replace(Regex("\\s+"), " ")
.trim()
}

View file

@ -21,6 +21,7 @@ data class PluginManifestScraper(
val filename: String,
@SerialName("supportedTypes") val supportedTypes: List<String> = listOf("movie", "tv"),
val enabled: Boolean = true,
val hasSettings: Boolean = false,
val logo: String? = null,
@SerialName("contentLanguage") val contentLanguage: List<String>? = null,
@SerialName("supportedPlatforms") val supportedPlatforms: List<String>? = null,
@ -52,6 +53,7 @@ data class PluginScraper(
val supportedTypes: List<String>,
val enabled: Boolean,
val manifestEnabled: Boolean,
val hasSettings: Boolean = false,
val logo: String? = null,
val contentLanguage: List<String> = emptyList(),
val formats: List<String>? = null,
@ -76,6 +78,15 @@ data class PluginRuntimeResult(
val peers: Int? = null,
val infoHash: String? = null,
val headers: Map<String, String>? = null,
val subtitles: List<PluginSubtitleResult>? = null,
)
@Serializable
data class PluginSubtitleResult(
val url: String,
val language: String,
val name: String? = null,
val headers: Map<String, String>? = null
)
data class PluginsUiState(
@ -119,6 +130,7 @@ internal data class StoredPluginScraper(
val supportedTypes: List<String>,
val enabled: Boolean,
val manifestEnabled: Boolean,
val hasSettings: Boolean = false,
val logo: String? = null,
val contentLanguage: List<String> = emptyList(),
val formats: List<String>? = null,

View file

@ -124,6 +124,14 @@ internal fun PluginRuntimeResult.toStreamItem(
proxyHeaders = StreamProxyHeaders(request = requestHeaders),
)
},
externalSubtitles = subtitles?.map {
StreamSubtitle(
url = it.url,
language = it.language,
name = it.name,
headers = it.headers
)
} ?: emptyList()
)
}

View file

@ -2,9 +2,18 @@ package com.nuvio.app.features.streams
import com.nuvio.app.core.build.AppFeaturePolicy
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@Serializable
data class StreamSubtitle(
val url: String,
val language: String,
val name: String? = null,
val headers: Map<String, String>? = null
)
data class StreamItem(
val name: String? = null,
val title: String? = null,
@ -22,6 +31,7 @@ data class StreamItem(
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
val clientResolve: StreamClientResolve? = null,
val debridCacheStatus: StreamDebridCacheStatus? = null,
val externalSubtitles: List<StreamSubtitle> = emptyList(),
val badges: List<StreamBadge> = emptyList(),
) {
val streamLabel: String

View file

@ -799,3 +799,4 @@ object StreamsRepository {
_uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) }
}
}

View file

@ -12,6 +12,7 @@ class PlayerLaunchStoreTest {
profileId = 1,
title = "Title",
sourceUrl = "https://example.com/video.m3u8?token=a/b:c",
externalSubtitles = emptyList(),
streamTitle = "Source",
providerName = "Provider",
parentMetaId = "tt1234567",

View file

@ -5,6 +5,7 @@ import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.plugins.runtime.PluginRuntime
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Order
import io.github.jan.supabase.postgrest.rpc
@ -337,7 +338,6 @@ actual object PluginRepository {
season = season,
episode = episode,
scraperId = scraper.id,
scraperSettings = emptyMap(),
)
}
}
@ -391,6 +391,7 @@ actual object PluginRepository {
supportedTypes = info.supportedTypes,
enabled = enabled,
manifestEnabled = info.enabled,
hasSettings = info.hasSettings,
logo = info.logo,
contentLanguage = info.contentLanguage ?: emptyList(),
formats = info.formats ?: info.supportedFormats,
@ -484,12 +485,12 @@ actual object PluginRepository {
supportedTypes = scraper.supportedTypes,
enabled = scraper.enabled,
manifestEnabled = scraper.manifestEnabled,
hasSettings = scraper.hasSettings,
logo = scraper.logo,
contentLanguage = scraper.contentLanguage,
formats = scraper.formats,
code = scraper.code,
)
},
) },
)
PluginStorage.saveState(currentProfileId, json.encodeToString(payload))
}
@ -551,6 +552,7 @@ actual object PluginRepository {
supportedTypes = it.supportedTypes,
enabled = it.enabled,
manifestEnabled = it.manifestEnabled,
hasSettings = it.hasSettings,
logo = it.logo,
contentLanguage = it.contentLanguage,
formats = it.formats,

View file

@ -1,994 +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 nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.generic_unknown
import org.jetbrains.compose.resources.getString
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<String, Any> = emptyMap(),
): List<PluginRuntimeResult> = 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<String, Any>,
): List<PluginRuntimeResult> {
val documentCache = mutableMapOf<String, Document>()
val elementCache = mutableMapOf<String, Element>()
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() ?: ""
val followRedirects = args.getOrNull(4) as? Boolean ?: true
try {
performNativeFetch(url, method, headersJson, body, followRedirects)
} 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<Any?>(polyfillCode)
val wrappedCode = """
var module = { exports: {} };
var exports = module.exports;
(function() {
$code
})();
""".trimIndent()
evaluate<Any?>(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<Any?>(callCode)
}
return parseJsonResults(resultJson)
} finally {
documentCache.clear()
elementCache.clear()
}
}
private fun performNativeFetch(
url: String,
method: String,
headersJson: String,
body: String,
followRedirects: Boolean,
): 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,
followRedirects = followRedirects,
)
}
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<String, String> {
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<PluginRuntimeResult> {
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") ?: runBlocking { getString(Res.string.generic_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 followRedirects = options.redirect !== 'manual';
var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects);
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()
}
}

View file

@ -0,0 +1,204 @@
package com.nuvio.app.features.plugins
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowDropDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioPrimaryButton
import kotlinx.serialization.json.*
@Composable
fun PluginSettingsDialog(
scraperId: String,
scraperName: String,
layoutJson: String,
onDismiss: () -> Unit
) {
val json = remember { Json { ignoreUnknownKeys = true } }
val layout = remember(layoutJson) {
runCatching { json.parseToJsonElement(layoutJson).jsonArray }.getOrElse { JsonArray(emptyList()) }
}
val savedSettingsJson = remember(scraperId) { PluginStorage.loadScraperSettings(scraperId) ?: "{}" }
val initialSettings = remember(savedSettingsJson) {
runCatching { json.parseToJsonElement(savedSettingsJson).jsonObject }.getOrElse { JsonObject(emptyMap()) }
}
val currentSettings = remember { mutableStateMapOf<String, JsonElement>().apply { putAll(initialSettings) } }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.large,
) {
Column(
modifier = Modifier
.padding(20.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "$scraperName Settings",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.primary
)
layout.forEach { element ->
val field = element.jsonObject
val type = field["type"]?.jsonPrimitive?.content ?: "info"
val key = field["key"]?.jsonPrimitive?.content ?: ""
val label = field["label"]?.jsonPrimitive?.content ?: ""
val description = field["description"]?.jsonPrimitive?.content
when (type) {
"header" -> {
Text(
text = label,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.secondary,
modifier = Modifier.padding(top = 8.dp)
)
}
"info" -> {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
"text" -> {
val value = currentSettings[key]?.jsonPrimitive?.content ?: ""
val isPassword = field["isPassword"]?.jsonPrimitive?.boolean ?: false
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = label, style = MaterialTheme.typography.labelLarge)
NuvioInputField(
value = value,
onValueChange = { currentSettings[key] = JsonPrimitive(it) },
placeholder = field["placeholder"]?.jsonPrimitive?.content ?: ""
)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
"select" -> {
val options = field["options"]?.jsonArray ?: JsonArray(emptyList())
val defaultValue = field["defaultValue"]?.jsonPrimitive?.content ?: ""
val currentValue = currentSettings[key]?.jsonPrimitive?.content ?: defaultValue
var expanded by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(text = label, style = MaterialTheme.typography.labelLarge)
Box {
OutlinedButton(
onClick = { expanded = true },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp),
contentPadding = PaddingValues(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val selectedLabel = options.find {
it.jsonObject["value"]?.jsonPrimitive?.content == currentValue
}?.jsonObject?.get("label")?.jsonPrimitive?.content ?: currentValue
Text(text = selectedLabel.ifBlank { "Select option" })
Icon(Icons.Rounded.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.fillMaxWidth(0.8f)
) {
options.forEach { optionElement ->
val option = optionElement.jsonObject
val optionLabel = option["label"]?.jsonPrimitive?.content ?: ""
val optionValue = option["value"]?.jsonPrimitive?.content ?: ""
DropdownMenuItem(
text = { Text(optionLabel) },
onClick = {
currentSettings[key] = JsonPrimitive(optionValue)
expanded = false
}
)
}
}
}
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
"toggle" -> {
val value = currentSettings[key]?.jsonPrimitive?.boolean ?: field["defaultValue"]?.jsonPrimitive?.boolean ?: false
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = label, style = MaterialTheme.typography.bodyLarge)
description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Switch(
checked = value,
onCheckedChange = { currentSettings[key] = JsonPrimitive(it) }
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End)
) {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
NuvioPrimaryButton(
text = "Save",
onClick = {
val result = JsonObject(currentSettings.toMap())
PluginStorage.saveScraperSettings(scraperId, result.toString())
onDismiss()
},
modifier = Modifier.width(100.dp)
)
}
}
}
}
}

View file

@ -12,8 +12,10 @@ 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.material.icons.rounded.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@ -39,6 +41,7 @@ import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import com.nuvio.app.features.plugins.runtime.PluginRuntime
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.plugins_badge_disabled
@ -101,6 +104,9 @@ fun PluginsSettingsPageContent(
var testingScraperId by remember { mutableStateOf<String?>(null) }
val testResults = remember { mutableStateMapOf<String, List<PluginRuntimeResult>>() }
var configuringScraper by remember { mutableStateOf<PluginScraper?>(null) }
var configuringLayout by remember { mutableStateOf<String?>(null) }
val sortedRepos = remember(uiState.repositories) {
uiState.repositories.sortedBy { it.name.lowercase() }
}
@ -408,11 +414,30 @@ fun PluginsSettingsPageContent(
)
}
}
Switch(
checked = scraper.enabled,
onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) },
enabled = scraper.manifestEnabled,
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (scraper.hasSettings) {
IconButton(onClick = {
coroutineScope.launch {
val layout = PluginRuntime.getPluginSettingsLayout(scraper.code, scraper.id)
if (layout != null) {
configuringScraper = scraper
configuringLayout = layout
}
}
}) {
Icon(
imageVector = Icons.Rounded.Settings,
contentDescription = "Provider settings",
tint = MaterialTheme.colorScheme.primary,
)
}
}
Switch(
checked = scraper.enabled,
onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) },
enabled = scraper.manifestEnabled,
)
}
}
Spacer(modifier = Modifier.height(10.dp))
@ -501,6 +526,18 @@ fun PluginsSettingsPageContent(
}
}
}
if (configuringScraper != null && configuringLayout != null) {
PluginSettingsDialog(
scraperId = configuringScraper!!.id,
scraperName = configuringScraper!!.name,
layoutJson = configuringLayout!!,
onDismiss = {
configuringScraper = null
configuringLayout = null
}
)
}
}
private fun String.fallbackRepositoryLabel(fallback: String): String {

View file

@ -0,0 +1,272 @@
package com.nuvio.app.features.plugins.runtime
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.plugins.runtime.crypto.CryptoBridge
import com.nuvio.app.features.plugins.runtime.dom.DomBridge
import com.nuvio.app.features.plugins.runtime.host.HostApiRegistry
import com.nuvio.app.features.plugins.runtime.host.HostFunctions
import com.nuvio.app.features.plugins.runtime.js.JsBindings
import com.nuvio.app.features.plugins.runtime.js.JsRuntime
import com.dokar.quickjs.binding.function
import com.nuvio.app.features.plugins.runtime.network.FetchBridge
import com.nuvio.app.features.plugins.runtime.network.UrlBridge
import com.nuvio.app.features.plugins.runtime.wasm.WasmBridge
import kotlinx.coroutines.CompletableDeferred
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 nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.generic_unknown
import org.jetbrains.compose.resources.getString
private const val PLUGIN_TIMEOUT_MS = 60_000L
internal object PluginRuntime {
private val json = Json { ignoreUnknownKeys = true }
suspend fun executePlugin(
code: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
scraperId: String,
): List<PluginRuntimeResult> = withContext(Dispatchers.Default) {
val scraperSettingsJson = PluginStorage.loadScraperSettings(scraperId) ?: "{}"
val scraperSettingsMap = runCatching {
json.decodeFromString<Map<String, JsonElement>>(scraperSettingsJson)
}.getOrElse { emptyMap() }
withTimeout(PLUGIN_TIMEOUT_MS) {
executePluginInternal(
code = code,
tmdbId = tmdbId,
mediaType = mediaType,
season = season,
episode = episode,
scraperId = scraperId,
scraperSettings = scraperSettingsMap,
)
}
}
suspend fun getPluginSettingsLayout(
code: String,
scraperId: String,
): String? = withContext(Dispatchers.Default) {
withTimeout(PLUGIN_TIMEOUT_MS) {
val jsRuntime = JsRuntime()
val deferred = CompletableDeferred<String?>()
try {
jsRuntime.use {
val polyfillCode = JsBindings.buildPolyfillCode(
scraperIdJson = JsonPrimitive(scraperId).toString(),
settingsJson = "{}"
)
evaluate<Any?>(polyfillCode)
val wrappedCode = """
var module = { exports: {} };
var exports = module.exports;
(function() {
$code
})();
""".trimIndent()
evaluate<Any?>(wrappedCode)
val callCode = """
(async function() {
try {
var onSettings = (typeof module !== 'undefined' && module.exports && module.exports.onSettings) || globalThis.onSettings;
if (typeof onSettings === 'function') {
var layout = await onSettings();
__capture_settings_result(JSON.stringify(layout || []));
} else {
__capture_settings_result("[]");
}
} catch (e) {
console.error("onSettings error:", e);
__capture_settings_result("[]");
}
})();
""".trimIndent()
function("__capture_settings_result") { args: Array<Any?> ->
deferred.complete(args.getOrNull(0)?.toString())
null
}
evaluate<Any?>(callCode)
deferred.await()
}
} catch (e: Exception) {
null
}
}
}
private suspend fun executePluginInternal(
code: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
scraperId: String,
scraperSettings: Map<String, JsonElement>,
): List<PluginRuntimeResult> {
val jsRuntime = JsRuntime()
val deferred = CompletableDeferred<String>()
val domBridge = DomBridge()
val hostRegistry = HostApiRegistry().apply {
addModule(HostFunctions(scraperId) { deferred.complete(it) })
addModule(FetchBridge())
addModule(UrlBridge())
addModule(CryptoBridge())
addModule(WasmBridge())
addModule(domBridge)
}
try {
jsRuntime.use {
hostRegistry.registerAll(this)
val settingsJson = JsonObject(scraperSettings).toString()
val polyfillCode = JsBindings.buildPolyfillCode(
scraperIdJson = JsonPrimitive(scraperId).toString(),
settingsJson = settingsJson,
)
evaluate<Any?>(polyfillCode)
val wrappedCode = """
var module = { exports: {} };
var exports = module.exports;
(function() {
$code
})();
""".trimIndent()
evaluate<Any?>(wrappedCode)
val tmdbIdArg = JsonPrimitive(tmdbId).toString()
val mediaTypeArg = JsonPrimitive(mediaType).toString()
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($tmdbIdArg, $mediaTypeArg, $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<Any?>(callCode)
deferred.await()
}
// Result is captured inside use block, but returned outside to satisfy compiler
return parseJsonResults(deferred.await())
} finally {
domBridge.clear()
}
}
private fun parseJsonResults(rawJson: String): List<PluginRuntimeResult> {
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() }
val subtitles = (item["subtitles"] as? JsonArray)?.mapNotNull { subElement ->
val subObj = subElement as? JsonObject ?: return@mapNotNull null
val subUrl = subObj["url"]?.jsonPrimitive?.contentOrNull ?: return@mapNotNull null
val subLang = subObj["language"]?.jsonPrimitive?.contentOrNull ?: "Unknown"
val subName = subObj["name"]?.jsonPrimitive?.contentOrNull
val subHeaders = (subObj["headers"] as? JsonObject)
?.mapNotNull { (key, value) ->
value.jsonPrimitive.contentOrNull?.let { key to it }
}
?.toMap()
?.takeIf { it.isNotEmpty() }
com.nuvio.app.features.plugins.PluginSubtitleResult(
url = subUrl,
language = subLang,
name = subName,
headers = subHeaders
)
}?.takeIf { it.isNotEmpty() }
PluginRuntimeResult(
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: runBlocking { getString(Res.string.generic_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,
subtitles = subtitles,
)
}.filter { it.url.isNotBlank() }
}.getOrElse { 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())
}
}

View file

@ -0,0 +1,135 @@
package com.nuvio.app.features.plugins.runtime.crypto
import com.dokar.quickjs.QuickJs
import com.dokar.quickjs.binding.function
import com.nuvio.app.features.plugins.runtime.host.HostModule
import com.nuvio.app.features.plugins.pluginDigestHex
import com.nuvio.app.features.plugins.pluginHmacHex
import com.nuvio.app.features.plugins.pluginBase64Encode
import com.nuvio.app.features.plugins.pluginBase64Decode
import com.nuvio.app.features.plugins.pluginUtf8ToHex
import com.nuvio.app.features.plugins.pluginHexToUtf8
import com.nuvio.app.features.plugins.pluginHexToByteArray
import com.nuvio.app.features.plugins.pluginGetRandomValues
import com.nuvio.app.features.plugins.pluginDigest
import com.nuvio.app.features.plugins.pluginHmac
import com.nuvio.app.features.plugins.pluginPbkdf2
import com.nuvio.app.features.plugins.pluginAesDecrypt
import com.nuvio.app.features.plugins.pluginAesEncrypt
import com.nuvio.app.features.plugins.pluginSign
import com.nuvio.app.features.plugins.pluginVerify
internal class CryptoBridge : HostModule {
override fun register(runtime: QuickJs) {
// Hex transport keeps binary data stable across the QuickJS/native bridge.
runtime.function("__crypto_get_random_values_hex") { args ->
val length = (args.getOrNull(0) as? Number)?.toInt() ?: 0
pluginGetRandomValues(length).toHexString()
}
runtime.function("__crypto_digest_hex_raw") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
val data = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
pluginDigest(algorithm, data).toHexString()
}
runtime.function("__crypto_hmac_hex_raw") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
val data = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "")
pluginHmac(algorithm, key, data).toHexString()
}
runtime.function("__crypto_pbkdf2_hex") { args ->
val password = pluginHexToByteArray(args.getOrNull(0)?.toString() ?: "")
val salt = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
val iterations = (args.getOrNull(2) as? Number)?.toInt() ?: 1000
val keySizeBits = (args.getOrNull(3) as? Number)?.toInt() ?: 256
val algorithm = args.getOrNull(4)?.toString() ?: "SHA256"
pluginPbkdf2(password, salt, iterations, keySizeBits, algorithm).toHexString()
}
runtime.function("__crypto_aes_encrypt_hex") { args ->
val mode = args.getOrNull(0)?.toString() ?: "AES-CBC"
val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
val iv = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "")
val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "")
pluginAesEncrypt(mode, key, iv, data).toHexString()
}
runtime.function("__crypto_aes_decrypt_hex") { args ->
val mode = args.getOrNull(0)?.toString() ?: "AES-CBC"
val key = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
val iv = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "")
val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "")
pluginAesDecrypt(mode, key, iv, data).toHexString()
}
runtime.function("__crypto_sign_hex") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: ""
val privateKey = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
val data = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "")
pluginSign(algorithm, privateKey, data).toHexString()
}
runtime.function("__crypto_verify_hex") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: ""
val publicKey = pluginHexToByteArray(args.getOrNull(1)?.toString() ?: "")
val signature = pluginHexToByteArray(args.getOrNull(2)?.toString() ?: "")
val data = pluginHexToByteArray(args.getOrNull(3)?.toString() ?: "")
pluginVerify(algorithm, publicKey, signature, data)
}
// --- Legacy Hex/String Bridges (Backward Compatibility) ---
runtime.function("__crypto_digest_hex") { args ->
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
val data = args.getOrNull(1)?.toString() ?: ""
runCatching {
pluginDigestHex(algorithm, data)
}.getOrDefault("")
}
runtime.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("")
}
runtime.function("__crypto_base64_encode") { args ->
val data = args.getOrNull(0)?.toString() ?: ""
runCatching {
pluginBase64Encode(data)
}.getOrDefault("")
}
runtime.function("__crypto_base64_decode") { args ->
val data = args.getOrNull(0)?.toString() ?: ""
runCatching {
pluginBase64Decode(data)
}.getOrDefault("")
}
runtime.function("__crypto_utf8_to_hex") { args ->
val data = args.getOrNull(0)?.toString() ?: ""
runCatching {
pluginUtf8ToHex(data)
}.getOrDefault("")
}
runtime.function("__crypto_hex_to_utf8") { args ->
val data = args.getOrNull(0)?.toString() ?: ""
runCatching {
pluginHexToUtf8(data)
}.getOrDefault("")
}
}
}
private fun ByteArray.toHexString(): String =
joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}

View file

@ -0,0 +1,118 @@
package com.nuvio.app.features.plugins.runtime.dom
import com.dokar.quickjs.QuickJs
import com.dokar.quickjs.binding.function
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.plugins.runtime.host.HostModule
import kotlin.random.Random
internal class DomBridge : HostModule {
private val documentCache = mutableMapOf<String, Document>()
private val elementCache = mutableMapOf<String, Element>()
private var idCounter = 0
private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""")
override fun register(runtime: QuickJs) {
runtime.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
}
runtime.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) {
"[]"
}
}
runtime.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) {
"[]"
}
}
runtime.function("__cheerio_text") { args ->
val elementIds = args.getOrNull(1)?.toString() ?: ""
elementIds.split(",")
.filter { it.isNotEmpty() }
.mapNotNull { elementCache[it]?.text() }
.joinToString(" ")
}
runtime.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() ?: ""
}
}
runtime.function("__cheerio_inner_html") { args ->
val elementId = args.getOrNull(1)?.toString() ?: ""
elementCache[elementId]?.html() ?: ""
}
runtime.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
}
runtime.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
}
runtime.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
}
}
fun clear() {
documentCache.clear()
elementCache.clear()
}
}

View file

@ -0,0 +1,19 @@
package com.nuvio.app.features.plugins.runtime.host
import com.dokar.quickjs.QuickJs
internal interface HostModule {
fun register(runtime: QuickJs)
}
internal class HostApiRegistry {
private val modules = mutableListOf<HostModule>()
fun addModule(module: HostModule) {
modules.add(module)
}
fun registerAll(runtime: QuickJs) {
modules.forEach { it.register(runtime) }
}
}

View file

@ -0,0 +1,43 @@
package com.nuvio.app.features.plugins.runtime.host
import co.touchlab.kermit.Logger
import com.dokar.quickjs.QuickJs
import com.dokar.quickjs.binding.define
import com.dokar.quickjs.binding.function
internal class HostFunctions(
private val scraperId: String,
private val onResult: (String) -> Unit
) : HostModule {
private val log = Logger.withTag("PluginRuntime")
override fun register(runtime: QuickJs) {
runtime.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
}
}
runtime.function("__capture_result") { args ->
onResult(args.getOrNull(0)?.toString() ?: "[]")
null
}
}
}

View file

@ -0,0 +1,973 @@
package com.nuvio.app.features.plugins.runtime.js
internal object JsBindings {
fun buildPolyfillCode(scraperIdJson: String, settingsJson: String): String {
return """
globalThis.SCRAPER_ID = $scraperIdJson;
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;
${fetchPolyfill()}
${abortControllerPolyfill()}
${base64Polyfill()}
${urlPolyfill()}
${cryptoPolyfill()}
${textEncoderPolyfill()}
${cheerioPolyfill()}
${requirePolyfill()}
${arrayPolyfill()}
${objectPolyfill()}
${stringPolyfill()}
""".trimIndent()
}
private fun fetchPolyfill() = """
function __normalize_fetch_headers(headers) {
var out = {};
if (!headers) return out;
if (typeof headers.forEach === 'function') {
headers.forEach(function(value, key) { out[key] = String(value); });
return out;
}
if (Array.isArray(headers)) {
headers.forEach(function(pair) {
if (pair && pair.length >= 2) out[pair[0]] = String(pair[1]);
});
return out;
}
Object.keys(headers).forEach(function(key) { out[key] = String(headers[key]); });
return out;
}
var fetch = async function(url, options) {
options = options || {};
var method = (options.method || 'GET').toUpperCase();
var headers = __normalize_fetch_headers(options.headers);
var body = options.body || '';
var followRedirects = options.redirect !== 'manual';
var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects);
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);
}
}
};
};
""".trimIndent()
private fun abortControllerPolyfill() = """
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;
}
""".trimIndent()
private fun base64Polyfill() = """
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;
};
}
""".trimIndent()
private fun urlPolyfill() = """
var __native_parse_url = typeof __parse_url !== 'undefined' ? __parse_url : function(u) { return JSON.stringify({ protocol: '', host: '', hostname: '', port: '', pathname: '/', search: '', hash: '' }); };
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 = __native_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;
};
""".trimIndent()
private fun cryptoPolyfill() = """
var WordArray = {
init: function(words, sigBytes) {
this.words = words || [];
this.sigBytes = sigBytes != undefined ? sigBytes : this.words.length * 4;
},
toString: function(encoder) {
return (encoder || CryptoJS.enc.Hex).stringify(this);
},
concat: function(wordArray) {
var thisWords = this.words;
var thatWords = wordArray.words;
var thisSigBytes = this.sigBytes;
var thatSigBytes = wordArray.sigBytes;
this.clamp();
for (var i = 0; i < thatSigBytes; i++) {
var thatByte = (thatWords[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
thisWords[(thisSigBytes + i) >>> 2] |= thatByte << (24 - ((thisSigBytes + i) % 4) * 8);
}
this.sigBytes += thatSigBytes;
return this;
},
clamp: function() {
var words = this.words;
var sigBytes = this.sigBytes;
if (sigBytes % 4) {
words[sigBytes >>> 2] &= 0xffffffff << (32 - (sigBytes % 4) * 8);
}
words.length = Math.ceil(sigBytes / 4);
return this;
},
clone: function() {
return __wordArrayCreate(this.words.slice(0), this.sigBytes);
}
};
function __wordArrayCreate(words, sigBytes) {
var wa = Object.create(WordArray);
wa.init(words, sigBytes);
return wa;
}
function __isWordArray(value) {
return value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number';
}
function __copyUint8Array(bytes) {
bytes = __toUint8Array(bytes);
var copy = new Uint8Array(bytes.length);
copy.set(bytes);
return copy;
}
function __toUint8Array(data) {
if (!data) return new Uint8Array(0);
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength);
}
if (Array.isArray(data)) return new Uint8Array(data);
if (typeof data.length === 'number') return new Uint8Array(Array.prototype.slice.call(data));
return new Uint8Array(0);
}
function __bytesToArrayBuffer(bytes) {
return __copyUint8Array(bytes).buffer;
}
function __wordArrayToBytes(wordArray) {
if (!__isWordArray(wordArray)) return typeof wordArray === 'string' ? new TextEncoder().encode(wordArray) : __toUint8Array(wordArray);
var bytes = new Uint8Array(wordArray.sigBytes);
for (var i = 0; i < wordArray.sigBytes; i++) {
bytes[i] = (wordArray.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
}
return bytes;
}
function __bytesToWordArray(bytes) {
bytes = __toUint8Array(bytes);
var words = [];
for (var i = 0; i < bytes.length; i++) {
words[i >>> 2] |= (bytes[i] & 0xff) << (24 - (i % 4) * 8);
}
return __wordArrayCreate(words, bytes.length);
}
function __normalizeWordArrayInput(value) {
if (__isWordArray(value)) return __wordArrayToBytes(value);
if (typeof value === 'string') return new TextEncoder().encode(value);
return __toUint8Array(value);
}
function __bytesToHex(bytes) {
bytes = __toUint8Array(bytes);
var out = [];
for (var i = 0; i < bytes.length; i++) {
var hex = bytes[i].toString(16);
out.push(hex.length < 2 ? '0' + hex : hex);
}
return out.join('');
}
function __hexToBytes(hex) {
hex = String(hex || '').replace(/[^0-9a-fA-F]/g, '');
if (hex.length % 2) hex = '0' + hex;
var bytes = new Uint8Array(hex.length / 2);
for (var i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16) & 0xff;
}
return bytes;
}
function __concatBytes() {
var total = 0;
var parts = [];
for (var i = 0; i < arguments.length; i++) {
var part = __toUint8Array(arguments[i]);
parts.push(part);
total += part.length;
}
var out = new Uint8Array(total);
var offset = 0;
for (var j = 0; j < parts.length; j++) {
out.set(parts[j], offset);
offset += parts[j].length;
}
return out;
}
function __normalizeHashName(hash) {
var name = hash && hash.name ? hash.name : hash;
name = String(name || 'SHA-256').toUpperCase().replace(/[^A-Z0-9]/g, '');
if (name === 'SHA1' || name === 'SHA256' || name === 'SHA384' || name === 'SHA512' || name === 'MD5') return name;
throw new Error('Unsupported hash algorithm: ' + name);
}
function __normalizeAlgorithmName(algo) {
var name = algo && algo.name ? algo.name : algo;
name = String(name || '').toUpperCase();
if (name.indexOf('AES-GCM') >= 0) return 'AES-GCM';
if (name.indexOf('AES-CBC') >= 0) return 'AES-CBC';
if (name.indexOf('AES-ECB') >= 0 || name === 'ECB') return 'AES-ECB';
if (name.indexOf('PBKDF2') >= 0) return 'PBKDF2';
if (name.indexOf('HMAC') >= 0) return 'HMAC';
if (name.indexOf('RSASSA-PKCS1') >= 0) return 'RSASSA-PKCS1-V1_5';
if (name.indexOf('ECDSA') >= 0) return 'ECDSA';
return name;
}
function __aesModeName(mode, padding) {
var normalized = __normalizeAlgorithmName(mode || 'AES-CBC');
if (padding === CryptoJS.pad.NoPadding || padding === 'NoPadding') normalized += '-NoPadding';
return normalized;
}
function __nativeDigestBytes(hash, dataBytes) {
if (typeof __crypto_digest_hex_raw === 'undefined') throw new Error('Native digest bridge is unavailable');
return __hexToBytes(__crypto_digest_hex_raw(__normalizeHashName(hash), __bytesToHex(dataBytes)));
}
function __nativeHmacBytes(hash, keyBytes, dataBytes) {
if (typeof __crypto_hmac_hex_raw === 'undefined') throw new Error('Native HMAC bridge is unavailable');
return __hexToBytes(__crypto_hmac_hex_raw(__normalizeHashName(hash), __bytesToHex(keyBytes), __bytesToHex(dataBytes)));
}
function __nativePbkdf2Bytes(passwordBytes, saltBytes, iterations, keySizeBits, hash) {
if (typeof __crypto_pbkdf2_hex === 'undefined') throw new Error('Native PBKDF2 bridge is unavailable');
return __hexToBytes(__crypto_pbkdf2_hex(__bytesToHex(passwordBytes), __bytesToHex(saltBytes), iterations, keySizeBits, __normalizeHashName(hash)));
}
function __nativeAesBytes(encrypt, mode, keyBytes, ivBytes, dataBytes) {
var fn = encrypt ? __crypto_aes_encrypt_hex : __crypto_aes_decrypt_hex;
if (typeof fn === 'undefined') throw new Error('Native AES bridge is unavailable');
return __hexToBytes(fn(mode, __bytesToHex(keyBytes), __bytesToHex(ivBytes), __bytesToHex(dataBytes)));
}
function __evpKdf(passwordBytes, saltBytes, keySizeBytes, ivSizeBytes) {
var targetSize = keySizeBytes + ivSizeBytes;
var derived = new Uint8Array(targetSize);
var block = new Uint8Array(0);
var offset = 0;
while (offset < targetSize) {
block = __nativeDigestBytes('MD5', __concatBytes(block, passwordBytes, saltBytes || new Uint8Array(0)));
var take = Math.min(block.length, targetSize - offset);
derived.set(block.subarray(0, take), offset);
offset += take;
}
return {
key: derived.subarray(0, keySizeBytes),
iv: derived.subarray(keySizeBytes, keySizeBytes + ivSizeBytes)
};
}
function __opensslSaltHeader() {
return new Uint8Array([83, 97, 108, 116, 101, 100, 95, 95]);
}
function __hasOpenSslSaltHeader(bytes) {
var header = __opensslSaltHeader();
if (!bytes || bytes.length < 16) return false;
for (var i = 0; i < header.length; i++) {
if (bytes[i] !== header[i]) return false;
}
return true;
}
function __makeCipherParams(ciphertext, key, iv, salt, mode) {
return {
ciphertext: __bytesToWordArray(ciphertext),
key: key ? __bytesToWordArray(key) : undefined,
iv: iv ? __bytesToWordArray(iv) : undefined,
salt: salt ? __bytesToWordArray(salt) : undefined,
mode: mode,
toString: function(formatter) {
return (formatter || CryptoJS.format.OpenSSL).stringify(this);
}
};
}
var CryptoJS = {
enc: {
Hex: {
stringify: function(wordArray) {
return __bytesToHex(__wordArrayToBytes(wordArray));
},
parse: function(hexStr) {
return __bytesToWordArray(__hexToBytes(hexStr));
}
},
Utf8: {
stringify: function(wordArray) {
return new TextDecoder('utf-8').decode(__wordArrayToBytes(wordArray));
},
parse: function(utf8Str) {
return __bytesToWordArray(new TextEncoder().encode(String(utf8Str)));
}
},
Latin1: {
stringify: function(wordArray) {
var bytes = __wordArrayToBytes(wordArray);
var out = '';
for (var i = 0; i < bytes.length; i++) out += String.fromCharCode(bytes[i]);
return out;
},
parse: function(str) {
str = String(str || '');
var bytes = new Uint8Array(str.length);
for (var i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xff;
return __bytesToWordArray(bytes);
}
},
Base64: {
stringify: function(wordArray) {
var bytes = __wordArrayToBytes(wordArray);
var binaryStr = '';
for (var j = 0; j < bytes.length; j++) binaryStr += String.fromCharCode(bytes[j]);
return btoa(binaryStr);
},
parse: function(base64Str) {
var binaryStr = atob(String(base64Str || ''));
var bytes = new Uint8Array(binaryStr.length);
for (var i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i) & 0xff;
return __bytesToWordArray(bytes);
}
},
Base64url: {
stringify: function(wordArray) {
return CryptoJS.enc.Base64.stringify(wordArray).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
},
parse: function(str) {
str = String(str || '').replace(/-/g, '+').replace(/_/g, '/');
while (str.length % 4) str += '=';
return CryptoJS.enc.Base64.parse(str);
}
}
},
lib: {
WordArray: {
create: function(words, sigBytes) {
if (words == null) return __wordArrayCreate([], sigBytes || 0);
if (__isWordArray(words)) return words.clone();
if (typeof words === 'string') return CryptoJS.enc.Utf8.parse(words);
if (words instanceof ArrayBuffer || (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView && ArrayBuffer.isView(words))) {
var bytes = __toUint8Array(words);
return __bytesToWordArray(sigBytes != undefined ? bytes.subarray(0, sigBytes) : bytes);
}
return __wordArrayCreate(words, sigBytes);
},
random: function(nBytes) {
var bytes = new Uint8Array(nBytes || 0);
globalThis.crypto.getRandomValues(bytes);
return __bytesToWordArray(bytes);
}
},
CipherParams: {
create: function(params) {
params = params || {};
params.toString = params.toString || function(formatter) {
return (formatter || CryptoJS.format.OpenSSL).stringify(this);
};
return params;
}
}
},
format: {
OpenSSL: {
stringify: function(cipherParams) {
var cipherBytes = __wordArrayToBytes(cipherParams.ciphertext);
var out = cipherParams.salt
? __concatBytes(__opensslSaltHeader(), __wordArrayToBytes(cipherParams.salt), cipherBytes)
: cipherBytes;
return CryptoJS.enc.Base64.stringify(__bytesToWordArray(out));
},
parse: function(str) {
var bytes = __wordArrayToBytes(CryptoJS.enc.Base64.parse(str));
if (__hasOpenSslSaltHeader(bytes)) {
return CryptoJS.lib.CipherParams.create({
salt: __bytesToWordArray(bytes.subarray(8, 16)),
ciphertext: __bytesToWordArray(bytes.subarray(16))
});
}
return CryptoJS.lib.CipherParams.create({ ciphertext: __bytesToWordArray(bytes) });
}
}
},
mode: { CBC: 'AES-CBC', GCM: 'AES-GCM', ECB: 'AES-ECB' },
pad: { Pkcs7: 'Pkcs7', NoPadding: 'NoPadding' },
algo: { MD5: 'MD5', SHA1: 'SHA1', SHA256: 'SHA256', SHA384: 'SHA384', SHA512: 'SHA512', AES: 'AES' },
MD5: function(m) { return __bytesToWordArray(__nativeDigestBytes('MD5', __normalizeWordArrayInput(m))); },
SHA1: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA1', __normalizeWordArrayInput(m))); },
SHA256: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA256', __normalizeWordArrayInput(m))); },
SHA384: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA384', __normalizeWordArrayInput(m))); },
SHA512: function(m) { return __bytesToWordArray(__nativeDigestBytes('SHA512', __normalizeWordArrayInput(m))); },
HmacMD5: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('MD5', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); },
HmacSHA1: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA1', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); },
HmacSHA256: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA256', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); },
HmacSHA384: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA384', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); },
HmacSHA512: function(m, k) { return __bytesToWordArray(__nativeHmacBytes('SHA512', __normalizeWordArrayInput(k), __normalizeWordArrayInput(m))); },
PBKDF2: function(pass, salt, options) {
options = options || {};
var pBytes = __normalizeWordArrayInput(pass);
var sBytes = __normalizeWordArrayInput(salt);
var iter = options.iterations || 1000;
var kSize = options.keySize || 8;
var algo = options.hasher || 'SHA1';
return __bytesToWordArray(__nativePbkdf2Bytes(pBytes, sBytes, iter, kSize * 32, algo));
},
AES: {
encrypt: function(message, key, options) {
options = options || {};
var data = __normalizeWordArrayInput(message);
var kBytes;
var ivBytes;
var saltBytes;
var isPassphrase = typeof key === 'string';
if (isPassphrase) {
saltBytes = options.salt ? __wordArrayToBytes(options.salt) : __wordArrayToBytes(CryptoJS.lib.WordArray.random(8));
var derived = __evpKdf(new TextEncoder().encode(key), saltBytes, 32, 16);
kBytes = derived.key;
ivBytes = options.iv ? __wordArrayToBytes(options.iv) : derived.iv;
} else {
kBytes = __wordArrayToBytes(key);
ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0);
}
var mode = __aesModeName(options.mode || 'AES-CBC', options.padding);
var resBytes = __nativeAesBytes(true, mode, kBytes, ivBytes, data);
return __makeCipherParams(resBytes, kBytes, ivBytes, saltBytes, mode);
},
decrypt: function(cipher, key, options) {
options = options || {};
var cipherParams = typeof cipher === 'string' ? CryptoJS.format.OpenSSL.parse(cipher) : cipher;
var data = cipherParams.ciphertext ? __wordArrayToBytes(cipherParams.ciphertext) : __toUint8Array(cipherParams);
var kBytes;
var ivBytes;
var isPassphrase = typeof key === 'string';
if (isPassphrase) {
var saltBytes = options.salt ? __wordArrayToBytes(options.salt) : (cipherParams.salt ? __wordArrayToBytes(cipherParams.salt) : new Uint8Array(0));
var derived = __evpKdf(new TextEncoder().encode(key), saltBytes, 32, 16);
kBytes = derived.key;
ivBytes = options.iv ? __wordArrayToBytes(options.iv) : derived.iv;
} else {
kBytes = __wordArrayToBytes(key);
ivBytes = options.iv ? __wordArrayToBytes(options.iv) : new Uint8Array(0);
}
var mode = __aesModeName(options.mode || 'AES-CBC', options.padding);
return __bytesToWordArray(__nativeAesBytes(false, mode, kBytes, ivBytes, data));
}
}
};
globalThis.CryptoJS = CryptoJS;
function __makeCryptoKey(type, algorithm, extractable, usages, rawBytes) {
return {
type: type,
extractable: !!extractable,
algorithm: algorithm,
usages: usages || [],
_raw: __copyUint8Array(rawBytes)
};
}
function __webCryptoAlgorithm(algo) {
var name = __normalizeAlgorithmName(algo);
var out = { name: name };
if (algo && typeof algo === 'object' && algo.length) out.length = algo.length;
if (algo && typeof algo === 'object' && algo.hash) out.hash = { name: __normalizeHashName(algo.hash) };
return out;
}
function __signatureAlgorithmName(algo, key) {
var name = __normalizeAlgorithmName(algo || (key && key.algorithm));
var hash = algo && algo.hash ? __normalizeHashName(algo.hash) : (key && key.algorithm && key.algorithm.hash ? key.algorithm.hash.name : 'SHA256');
if (name === 'RSASSA-PKCS1-V1_5') return 'RSASSA-PKCS1-V1_5-' + hash;
if (name === 'ECDSA') return 'ECDSA-' + hash;
return name;
}
globalThis.crypto = {
subtle: {
digest: async function(algo, data) {
return __bytesToArrayBuffer(__nativeDigestBytes(algo, __toUint8Array(data)));
},
importKey: async function(fmt, data, algo, extractable, usages) {
fmt = String(fmt || 'raw').toLowerCase();
if (fmt !== 'raw' && fmt !== 'pkcs8' && fmt !== 'spki') throw new Error('Unsupported key format: ' + fmt);
var algorithm = __webCryptoAlgorithm(algo || {});
var type = fmt === 'spki' ? 'public' : (fmt === 'pkcs8' ? 'private' : 'secret');
return __makeCryptoKey(type, algorithm, extractable, usages || [], __toUint8Array(data));
},
exportKey: async function(fmt, key) {
fmt = String(fmt || 'raw').toLowerCase();
if (fmt !== 'raw' && fmt !== 'pkcs8' && fmt !== 'spki') throw new Error('Unsupported key format: ' + fmt);
return __bytesToArrayBuffer(key._raw);
},
generateKey: async function(algo, extractable, usages) {
var algorithm = __webCryptoAlgorithm(algo || {});
if (algorithm.name !== 'AES-CBC' && algorithm.name !== 'AES-GCM' && algorithm.name !== 'HMAC') {
throw new Error('Unsupported generateKey algorithm: ' + algorithm.name);
}
var length = algorithm.length || 256;
var bytes = new Uint8Array(length / 8);
globalThis.crypto.getRandomValues(bytes);
return __makeCryptoKey('secret', algorithm, extractable, usages || [], bytes);
},
deriveBits: async function(params, key, len) {
if (__normalizeAlgorithmName(params) !== 'PBKDF2') throw new Error('Only PBKDF2 deriveBits is supported');
var pBytes = __toUint8Array(key._raw);
var sBytes = __toUint8Array(params.salt);
var hash = params.hash || 'SHA-256';
return __bytesToArrayBuffer(__nativePbkdf2Bytes(pBytes, sBytes, params.iterations || 1000, len, hash));
},
deriveKey: async function(params, key, derivedKeyAlgo, extractable, usages) {
var algorithm = __webCryptoAlgorithm(derivedKeyAlgo || {});
var length = algorithm.length || 256;
var raw = await globalThis.crypto.subtle.deriveBits(params, key, length);
return __makeCryptoKey('secret', algorithm, extractable, usages || [], new Uint8Array(raw));
},
encrypt: async function(params, key, data) {
var mode = __normalizeAlgorithmName(params);
if (mode !== 'AES-CBC' && mode !== 'AES-GCM') throw new Error('Unsupported encrypt algorithm: ' + mode);
if (mode === 'AES-GCM' && params.tagLength && params.tagLength !== 128) throw new Error('Only 128-bit AES-GCM tags are supported');
if (mode === 'AES-GCM' && params.additionalData) throw new Error('AES-GCM additionalData is not supported');
var ivBytes = __toUint8Array(params.iv || new Uint8Array(0));
return __bytesToArrayBuffer(__nativeAesBytes(true, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data)));
},
decrypt: async function(params, key, data) {
var mode = __normalizeAlgorithmName(params);
if (mode !== 'AES-CBC' && mode !== 'AES-GCM') throw new Error('Unsupported decrypt algorithm: ' + mode);
if (mode === 'AES-GCM' && params.tagLength && params.tagLength !== 128) throw new Error('Only 128-bit AES-GCM tags are supported');
if (mode === 'AES-GCM' && params.additionalData) throw new Error('AES-GCM additionalData is not supported');
var ivBytes = __toUint8Array(params.iv || new Uint8Array(0));
return __bytesToArrayBuffer(__nativeAesBytes(false, mode, __toUint8Array(key._raw), ivBytes, __toUint8Array(data)));
},
sign: async function(algo, key, data) {
if (__normalizeAlgorithmName(algo || key.algorithm) === 'HMAC' || key.algorithm.name === 'HMAC') {
var hash = (algo && algo.hash) || (key.algorithm && key.algorithm.hash) || 'SHA-256';
return __bytesToArrayBuffer(__nativeHmacBytes(hash, __toUint8Array(key._raw), __toUint8Array(data)));
}
if (typeof __crypto_sign_hex === 'undefined') throw new Error('Native signature bridge is unavailable');
var sigHex = __crypto_sign_hex(__signatureAlgorithmName(algo, key), __bytesToHex(key._raw), __bytesToHex(__toUint8Array(data)));
return __bytesToArrayBuffer(__hexToBytes(sigHex));
},
verify: async function(algo, key, sig, data) {
if (__normalizeAlgorithmName(algo || key.algorithm) === 'HMAC' || key.algorithm.name === 'HMAC') {
var expected = __nativeHmacBytes((algo && algo.hash) || (key.algorithm && key.algorithm.hash) || 'SHA-256', __toUint8Array(key._raw), __toUint8Array(data));
var actual = __toUint8Array(sig);
if (expected.length !== actual.length) return false;
var diff = 0;
for (var i = 0; i < expected.length; i++) diff |= expected[i] ^ actual[i];
return diff === 0;
}
if (typeof __crypto_verify_hex === 'undefined') throw new Error('Native signature bridge is unavailable');
return __crypto_verify_hex(__signatureAlgorithmName(algo, key), __bytesToHex(key._raw), __bytesToHex(__toUint8Array(sig)), __bytesToHex(__toUint8Array(data)));
}
},
getRandomValues: function(arr) {
if (!arr) return arr;
var byteLength = arr.byteLength != undefined ? arr.byteLength : arr.length;
if (!byteLength) return arr;
if (typeof __crypto_get_random_values_hex === 'undefined') throw new Error('Native random bridge is unavailable');
var random = __hexToBytes(__crypto_get_random_values_hex(byteLength));
if (arr.buffer && arr.byteLength != undefined) {
new Uint8Array(arr.buffer, arr.byteOffset || 0, arr.byteLength).set(random);
} else {
for (var i = 0; i < arr.length; i++) arr[i] = random[i] || 0;
}
return arr;
},
randomUUID: function() {
var b = new Uint8Array(16);
globalThis.crypto.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
var h = __bytesToHex(b);
return h.substr(0, 8) + '-' + h.substr(8, 4) + '-' + h.substr(12, 4) + '-' + h.substr(16, 4) + '-' + h.substr(20);
}
};
// WebAssembly placeholder
globalThis.WebAssembly = {
instantiate: async function(bufferSource, importObject) {
console.warn("WebAssembly.instantiate called (placeholder)");
return { instance: { exports: {} }, module: {} };
}
};
""".trimIndent()
private fun textEncoderPolyfill() = """
if (typeof TextEncoder === 'undefined') {
globalThis.TextEncoder = function() {};
TextEncoder.prototype.encode = function(str) {
var hex = __crypto_utf8_to_hex(str);
var bytes = new Uint8Array(hex.length / 2);
for (var i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
};
}
if (typeof TextDecoder === 'undefined') {
globalThis.TextDecoder = function() {};
TextDecoder.prototype.decode = function(data) {
var bytes = data;
if (data instanceof ArrayBuffer) bytes = new Uint8Array(data);
var hex = '';
for (var i = 0; i < bytes.length; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return __crypto_hex_to_utf8(hex);
};
}
""".trimIndent()
private fun cheerioPolyfill() = """
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;
}
""".trimIndent()
private fun requirePolyfill() = """
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");
};
""".trimIndent()
private fun arrayPolyfill() = """
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(); };
}
""".trimIndent()
private fun objectPolyfill() = """
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;
};
}
""".trimIndent()
private fun stringPolyfill() = """
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()
}

View file

@ -0,0 +1,16 @@
package com.nuvio.app.features.plugins.runtime.js
import com.dokar.quickjs.QuickJs
import com.dokar.quickjs.quickJs
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
internal class JsRuntime(
private val dispatcher: CoroutineDispatcher = Dispatchers.Default
) {
suspend fun <T> use(block: suspend QuickJs.() -> T): T {
return quickJs(dispatcher) {
block()
}
}
}

View file

@ -0,0 +1,101 @@
package com.nuvio.app.features.plugins.runtime.network
import co.touchlab.kermit.Logger
import com.dokar.quickjs.QuickJs
import com.dokar.quickjs.binding.function
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.plugins.runtime.host.HostModule
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024
private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]"
internal class FetchBridge : HostModule {
private val log = Logger.withTag("PluginRuntime")
private val json = Json { ignoreUnknownKeys = true }
override fun register(runtime: QuickJs) {
runtime.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() ?: ""
val followRedirects = args.getOrNull(4) as? Boolean ?: true
try {
performNativeFetch(url, method, headersJson, body, followRedirects)
} 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()
}
}
}
private fun performNativeFetch(
url: String,
method: String,
headersJson: String,
body: String,
followRedirects: Boolean,
): String {
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,
followRedirects = followRedirects,
)
}
val responseHeaders = response.headers.mapKeys { (key, _) -> key.lowercase() }
.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(response.body),
"headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }),
),
)
return result.toString()
}
private fun parseHeaders(headersJson: String): Map<String, String> {
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 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
}
}

View file

@ -0,0 +1,53 @@
package com.nuvio.app.features.plugins.runtime.network
import com.dokar.quickjs.QuickJs
import com.dokar.quickjs.binding.function
import com.nuvio.app.features.plugins.runtime.host.HostModule
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal class UrlBridge : HostModule {
override fun register(runtime: QuickJs) {
runtime.function("__parse_url") { args ->
val urlString = args.getOrNull(0)?.toString() ?: ""
parseUrl(urlString)
}
}
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()
}
}
}

View file

@ -0,0 +1,16 @@
package com.nuvio.app.features.plugins.runtime.wasm
import com.dokar.quickjs.QuickJs
import com.nuvio.app.features.plugins.runtime.host.HostModule
/**
* Lightweight WASM Helpers bridge.
* TODO: In the future, this will integrate a lightweight WASM interpreter like Chasm or wasm-interp.js
* to support advanced extraction logic (e.g. FlixCloud).
*/
internal class WasmBridge : HostModule {
override fun register(runtime: QuickJs) {
// Placeholder for WASM instantiation bridge
// runtime.function("__native_wasm_instantiate") { ... }
}
}

View file

@ -2,21 +2,396 @@ package com.nuvio.app.features.plugins
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.refTo
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.usePinned
import kotlinx.cinterop.reinterpret
import kotlinx.cinterop.UByteVar
import kotlinx.cinterop.ByteVar
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.value
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
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA1_DIGEST_LENGTH
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA256_DIGEST_LENGTH
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512
import com.nuvio.app.features.plugins.cryptointerop.CC_SHA512_DIGEST_LENGTH
import com.nuvio.app.features.plugins.cryptointerop.CCHmac
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgMD5
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA1
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA256
import com.nuvio.app.features.plugins.cryptointerop.kCCHmacAlgSHA512
import com.nuvio.app.features.plugins.cryptointerop.*
import platform.Security.SecRandomCopyBytes
import platform.Security.kSecRandomDefault
internal fun pluginGetRandomValues(length: Int): ByteArray {
require(length >= 0) { "Random byte length must be non-negative" }
if (length == 0) return ByteArray(0)
val bytes = ByteArray(length)
@OptIn(ExperimentalForeignApi::class)
val status = SecRandomCopyBytes(kSecRandomDefault, length.toULong(), bytes.refTo(0))
require(status == 0) { "Failed to generate secure random bytes: status $status" }
return bytes
}
@OptIn(ExperimentalForeignApi::class)
internal fun pluginDigest(algorithm: String, data: ByteArray): ByteArray {
val normalized = normalizeDigestAlgorithm(algorithm)
val output = ByteArray(
when (normalized) {
"MD5" -> CC_MD5_DIGEST_LENGTH.toInt()
"SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt()
"SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt()
"SHA384" -> CC_SHA384_DIGEST_LENGTH.toInt()
"SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt()
else -> error("Unsupported digest algorithm: $algorithm")
},
)
data.usePinned { pinnedData ->
output.usePinned { pinnedOutput ->
val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null
val outputPtr = pinnedOutput.addressOf(0).reinterpret<UByteVar>()
when (normalized) {
"MD5" -> CC_MD5(dataPtr, data.size.toUInt(), outputPtr)
"SHA1" -> CC_SHA1(dataPtr, data.size.toUInt(), outputPtr)
"SHA256" -> CC_SHA256(dataPtr, data.size.toUInt(), outputPtr)
"SHA384" -> CC_SHA384(dataPtr, data.size.toUInt(), outputPtr)
"SHA512" -> CC_SHA512(dataPtr, data.size.toUInt(), outputPtr)
}
}
}
return output
}
@OptIn(ExperimentalForeignApi::class)
internal fun pluginPbkdf2(
password: ByteArray,
salt: ByteArray,
iterations: Int,
keySizeBits: Int,
algorithm: String,
): ByteArray {
require(iterations > 0) { "PBKDF2 iterations must be positive" }
require(keySizeBits > 0 && keySizeBits % 8 == 0) { "PBKDF2 key size must be a positive byte-aligned bit length" }
val prf = normalizePbkdf2Prf(algorithm)
val derivedKeyLen = keySizeBits / 8
val derivedKey = ByteArray(derivedKeyLen)
password.usePinned { pinnedPassword ->
salt.usePinned { pinnedSalt ->
derivedKey.usePinned { pinnedDerivedKey ->
val passwordPtr = if (password.isNotEmpty()) pinnedPassword.addressOf(0).reinterpret<ByteVar>() else null
val saltPtr = if (salt.isNotEmpty()) pinnedSalt.addressOf(0).reinterpret<UByteVar>() else null
val derivedKeyPtr = pinnedDerivedKey.addressOf(0).reinterpret<UByteVar>()
val status = CCKeyDerivationPBKDF(
algorithm = kCCPBKDF2,
password = passwordPtr,
passwordLen = password.size.toULong(),
salt = saltPtr,
saltLen = salt.size.toULong(),
prf = prf,
rounds = iterations.toUInt(),
derivedKey = derivedKeyPtr,
derivedKeyLen = derivedKeyLen.toULong()
)
require(status == kCCSuccess) { "PBKDF2 failed with status: $status" }
}
}
}
return derivedKey
}
@OptIn(ExperimentalForeignApi::class)
internal fun pluginAesEncrypt(
mode: String,
key: ByteArray,
iv: ByteArray,
data: ByteArray,
): ByteArray {
requireValidAesKey(key)
if (!mode.uppercase().contains("ECB")) {
require(iv.isNotEmpty()) { "AES mode $mode requires an IV" }
}
val isGcm = mode.uppercase().contains("GCM")
if (isGcm) {
var encryptedData: ByteArray? = null
memScoped {
val cryptorRefVar = alloc<com.nuvio.app.features.plugins.cryptointerop.CCCryptorRefVar>()
key.usePinned { pinnedKey ->
iv.usePinned { pinnedIv ->
data.usePinned { pinnedData ->
val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null
val ivPtr = if (iv.isNotEmpty()) pinnedIv.addressOf(0) else null
val dataPtr = if (data.isNotEmpty()) pinnedData.addressOf(0) else null
val status = CCCryptorCreateWithMode(
op = kCCEncrypt,
mode = kCCModeGCM,
alg = kCCAlgorithmAES,
padding = ccNoPadding,
iv = ivPtr,
key = keyPtr,
keyLength = key.size.toULong(),
tweak = null,
tweakLength = 0UL,
numRounds = 0,
options = 0U,
cryptorRef = cryptorRefVar.ptr
)
if (status != kCCSuccess) {
error("CCCryptorCreateWithMode failed with status: $status")
}
val cryptorRef = cryptorRefVar.value ?: error("Cryptor reference was null")
try {
val cipherTextBytes = ByteArray(data.size)
cipherTextBytes.usePinned { pinnedCipher ->
val cipherPtr = if (data.isNotEmpty()) pinnedCipher.addressOf(0) else null
val cryptStatus = CCCryptorGCMEncrypt(
cryptorRef = cryptorRef,
dataIn = dataPtr,
dataInLength = data.size.toULong(),
dataOut = cipherPtr
)
if (cryptStatus != kCCSuccess) {
error("CCCryptorGCMEncrypt failed with status: $cryptStatus")
}
}
val tagBytes = ByteArray(16)
val tagLengthVar = alloc<platform.posix.size_tVar>()
tagLengthVar.value = 16UL
tagBytes.usePinned { pinnedTag ->
val tagPtr = pinnedTag.addressOf(0)
val finalStatus = CCCryptorGCMFinal(
cryptorRef = cryptorRef,
tag = tagPtr,
tagLength = tagLengthVar.ptr
)
if (finalStatus != kCCSuccess) {
error("CCCryptorGCMFinal failed with status: $finalStatus")
}
}
encryptedData = cipherTextBytes + tagBytes
} finally {
CCCryptorRelease(cryptorRef)
}
}
}
}
}
return encryptedData ?: ByteArray(0)
}
val isEcb = mode.uppercase().contains("ECB")
val isNoPadding = mode.uppercase().contains("NOPADDING")
val dataOutAvailable = data.size + 16 // AES block size
val dataOut = ByteArray(dataOutAvailable)
var finalData: ByteArray? = null
memScoped {
val dataOutMoved = alloc<platform.posix.size_tVar>()
var options = 0U
if (isEcb) {
options = options or kCCOptionECBMode
}
if (!isNoPadding) {
options = options or kCCOptionPKCS7Padding
}
key.usePinned { pinnedKey ->
iv.usePinned { pinnedIv ->
data.usePinned { pinnedData ->
dataOut.usePinned { pinnedDataOut ->
val status = CCCrypt(
op = kCCEncrypt,
alg = kCCAlgorithmAES,
options = options,
key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null,
keyLength = key.size.toULong(),
iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null,
dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null,
dataInLength = data.size.toULong(),
dataOut = pinnedDataOut.addressOf(0),
dataOutAvailable = dataOutAvailable.toULong(),
dataOutMoved = dataOutMoved.ptr
)
if (status == kCCSuccess) {
finalData = dataOut.copyOf(dataOutMoved.value.toInt())
} else {
error("CCCrypt Encrypt failed with status: $status")
}
}
}
}
}
}
return finalData ?: ByteArray(0)
}
@OptIn(ExperimentalForeignApi::class)
internal fun pluginAesDecrypt(
mode: String,
key: ByteArray,
iv: ByteArray,
data: ByteArray,
): ByteArray {
requireValidAesKey(key)
if (!mode.uppercase().contains("ECB")) {
require(iv.isNotEmpty()) { "AES mode $mode requires an IV" }
}
val isGcm = mode.uppercase().contains("GCM")
if (isGcm) {
require(data.size >= 16) { "Data too short for GCM decryption" }
val ciphertextLen = data.size - 16
val ciphertext = data.copyOfRange(0, ciphertextLen)
val tagBytes = data.copyOfRange(ciphertextLen, data.size)
var decryptedData: ByteArray? = null
memScoped {
val cryptorRefVar = alloc<com.nuvio.app.features.plugins.cryptointerop.CCCryptorRefVar>()
key.usePinned { pinnedKey ->
iv.usePinned { pinnedIv ->
ciphertext.usePinned { pinnedCipher ->
tagBytes.usePinned { pinnedTag ->
val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null
val ivPtr = if (iv.isNotEmpty()) pinnedIv.addressOf(0) else null
val cipherPtr = if (ciphertext.isNotEmpty()) pinnedCipher.addressOf(0) else null
val tagPtr = pinnedTag.addressOf(0)
val status = CCCryptorCreateWithMode(
op = kCCDecrypt,
mode = kCCModeGCM,
alg = kCCAlgorithmAES,
padding = ccNoPadding,
iv = ivPtr,
key = keyPtr,
keyLength = key.size.toULong(),
tweak = null,
tweakLength = 0UL,
numRounds = 0,
options = 0U,
cryptorRef = cryptorRefVar.ptr
)
if (status != kCCSuccess) {
error("CCCryptorCreateWithMode failed with status: $status")
}
val cryptorRef = cryptorRefVar.value ?: error("Cryptor reference was null")
try {
val plainTextBytes = ByteArray(ciphertextLen)
plainTextBytes.usePinned { pinnedPlain ->
val plainPtr = if (ciphertextLen > 0) pinnedPlain.addressOf(0) else null
val cryptStatus = CCCryptorGCMDecrypt(
cryptorRef = cryptorRef,
dataIn = cipherPtr,
dataInLength = ciphertextLen.toULong(),
dataOut = plainPtr
)
if (cryptStatus != kCCSuccess) {
error("CCCryptorGCMDecrypt failed with status: $cryptStatus")
}
}
val tagLengthVar = alloc<platform.posix.size_tVar>()
tagLengthVar.value = 16UL
val finalStatus = CCCryptorGCMFinal(
cryptorRef = cryptorRef,
tag = tagPtr,
tagLength = tagLengthVar.ptr
)
if (finalStatus != kCCSuccess) {
error("CCCryptorGCMFinal failed with status: $finalStatus (tag verification failed)")
}
decryptedData = plainTextBytes
} finally {
CCCryptorRelease(cryptorRef)
}
}
}
}
}
}
return decryptedData ?: ByteArray(0)
}
val isEcb = mode.uppercase().contains("ECB")
val isNoPadding = mode.uppercase().contains("NOPADDING")
val dataOutAvailable = data.size + 16 // AES block size
val dataOut = ByteArray(dataOutAvailable)
var finalData: ByteArray? = null
memScoped {
val dataOutMoved = alloc<platform.posix.size_tVar>()
var options = 0U
if (isEcb) {
options = options or kCCOptionECBMode
}
if (!isNoPadding) {
options = options or kCCOptionPKCS7Padding
}
key.usePinned { pinnedKey ->
iv.usePinned { pinnedIv ->
data.usePinned { pinnedData ->
dataOut.usePinned { pinnedDataOut ->
val status = CCCrypt(
op = kCCDecrypt,
alg = kCCAlgorithmAES,
options = options,
key = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null,
keyLength = key.size.toULong(),
iv = if (!isEcb && iv.isNotEmpty()) pinnedIv.addressOf(0) else null,
dataIn = if (data.isNotEmpty()) pinnedData.addressOf(0) else null,
dataInLength = data.size.toULong(),
dataOut = pinnedDataOut.addressOf(0),
dataOutAvailable = dataOutAvailable.toULong(),
dataOutMoved = dataOutMoved.ptr
)
if (status == kCCSuccess) {
finalData = dataOut.copyOf(dataOutMoved.value.toInt())
} else {
error("CCCrypt failed with status: $status")
}
}
}
}
}
}
return finalData ?: ByteArray(0)
}
internal fun pluginSign(algorithm: String, privateKey: ByteArray, data: ByteArray): ByteArray {
throw UnsupportedOperationException("Asymmetric signing is currently implemented natively only on Android")
}
internal fun pluginVerify(algorithm: String, publicKey: ByteArray, signature: ByteArray, data: ByteArray): Boolean {
throw UnsupportedOperationException("Asymmetric verification is currently implemented natively only on Android")
}
private fun UByteArray.toHex(): String = joinToString(separator = "") { byte ->
byte.toString(16).padStart(2, '0')
@ -24,62 +399,130 @@ private fun UByteArray.toHex(): String = joinToString(separator = "") { byte ->
@OptIn(ExperimentalForeignApi::class)
internal fun pluginDigestHex(algorithm: String, data: String): String {
val normalized = algorithm.uppercase()
val normalized = normalizeDigestAlgorithm(algorithm)
val input = data.encodeToByteArray()
val output = UByteArray(
when (normalized) {
"MD5" -> CC_MD5_DIGEST_LENGTH.toInt()
"SHA1" -> CC_SHA1_DIGEST_LENGTH.toInt()
"SHA256" -> CC_SHA256_DIGEST_LENGTH.toInt()
"SHA384" -> CC_SHA384_DIGEST_LENGTH.toInt()
"SHA512" -> CC_SHA512_DIGEST_LENGTH.toInt()
else -> error("Unsupported digest algorithm: $algorithm")
},
)
when (normalized) {
"MD5" -> CC_MD5(input.refTo(0), input.size.toUInt(), output.refTo(0))
"SHA1" -> CC_SHA1(input.refTo(0), input.size.toUInt(), output.refTo(0))
"SHA256" -> CC_SHA256(input.refTo(0), input.size.toUInt(), output.refTo(0))
"SHA512" -> CC_SHA512(input.refTo(0), input.size.toUInt(), output.refTo(0))
input.usePinned { pinnedInput ->
output.usePinned { pinnedOutput ->
val dataPtr = if (input.isNotEmpty()) pinnedInput.addressOf(0) else null
val outputPtr = pinnedOutput.addressOf(0)
when (normalized) {
"MD5" -> CC_MD5(dataPtr, input.size.toUInt(), outputPtr)
"SHA1" -> CC_SHA1(dataPtr, input.size.toUInt(), outputPtr)
"SHA256" -> CC_SHA256(dataPtr, input.size.toUInt(), outputPtr)
"SHA384" -> CC_SHA384(dataPtr, input.size.toUInt(), outputPtr)
"SHA512" -> CC_SHA512(dataPtr, input.size.toUInt(), outputPtr)
}
}
}
return output.toHex()
}
@OptIn(ExperimentalForeignApi::class)
internal fun pluginHmacHex(algorithm: String, key: String, data: String): String {
val normalized = algorithm.uppercase()
val keyBytes = key.encodeToByteArray()
val input = data.encodeToByteArray()
internal fun pluginHmac(algorithm: String, key: ByteArray, data: ByteArray): ByteArray {
val (alg, outputSize) = normalizeHmacAlgorithm(algorithm)
val output = ByteArray(outputSize)
val (alg, outputSize) = when (normalized) {
key.usePinned { pinnedKey ->
data.usePinned { pinnedInput ->
output.usePinned { pinnedOutput ->
val keyPtr = if (key.isNotEmpty()) pinnedKey.addressOf(0) else null
val inputPtr = if (data.isNotEmpty()) pinnedInput.addressOf(0) else null
CCHmac(
alg,
keyPtr,
key.size.toULong(),
inputPtr,
data.size.toULong(),
pinnedOutput.addressOf(0).reinterpret<UByteVar>(),
)
}
}
}
return output
}
@OptIn(ExperimentalForeignApi::class)
internal fun pluginHmacHex(algorithm: String, key: String, data: String): String {
return pluginHmac(algorithm, key.encodeToByteArray(), data.encodeToByteArray()).toHex()
}
private fun normalizeDigestAlgorithm(algorithm: String): String {
return when (algorithm.normalizedAlgorithmToken()) {
"MD5" -> "MD5"
"SHA1" -> "SHA1"
"SHA256" -> "SHA256"
"SHA384" -> "SHA384"
"SHA512" -> "SHA512"
else -> error("Unsupported digest algorithm: $algorithm")
}
}
@OptIn(ExperimentalForeignApi::class)
private fun normalizePbkdf2Prf(algorithm: String) =
when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) {
"SHA1" -> kCCPRFHmacAlgSHA1
"SHA256" -> kCCPRFHmacAlgSHA256
"SHA384" -> kCCPRFHmacAlgSHA384
"SHA512" -> kCCPRFHmacAlgSHA512
else -> error("Unsupported PBKDF2 hash algorithm: $algorithm")
}
@OptIn(ExperimentalForeignApi::class)
private fun normalizeHmacAlgorithm(algorithm: String) =
when (algorithm.normalizedAlgorithmToken().removePrefix("HMAC")) {
"MD5" -> kCCHmacAlgMD5 to CC_MD5_DIGEST_LENGTH.toInt()
"SHA1" -> kCCHmacAlgSHA1 to CC_SHA1_DIGEST_LENGTH.toInt()
"SHA256" -> kCCHmacAlgSHA256 to CC_SHA256_DIGEST_LENGTH.toInt()
"SHA384" -> kCCHmacAlgSHA384 to CC_SHA384_DIGEST_LENGTH.toInt()
"SHA512" -> kCCHmacAlgSHA512 to CC_SHA512_DIGEST_LENGTH.toInt()
else -> error("Unsupported HMAC algorithm: $algorithm")
}
val output = UByteArray(outputSize)
CCHmac(
alg,
keyBytes.refTo(0),
keyBytes.size.toULong(),
input.refTo(0),
input.size.toULong(),
output.refTo(0),
)
return output.toHex()
private fun requireValidAesKey(key: ByteArray) {
require(key.size == 16 || key.size == 24 || key.size == 32) {
"AES key must be 16, 24, or 32 bytes"
}
}
private fun ByteArray.toHex(): String =
joinToString(separator = "") { byte ->
byte.toUByte().toString(16).padStart(2, '0')
}
private fun String.normalizedAlgorithmToken(): String =
uppercase()
.replace("-", "")
.replace("_", "")
.replace("/", "")
.replace(" ", "")
@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(" ", "")
var normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "")
normalized = normalized.replace("-", "+").replace("_", "/")
val padNeeded = (4 - (normalized.length % 4)) % 4
if (padNeeded > 0) {
normalized += "=".repeat(padNeeded)
}
val decoded = Base64.decode(normalized)
return decoded.decodeToString()
}
@ -89,11 +532,11 @@ internal fun pluginUtf8ToHex(value: String): String =
byte.toUByte().toString(16).padStart(2, '0')
}
internal fun pluginHexToUtf8(hex: String): String {
internal fun pluginHexToByteArray(hex: String): ByteArray {
val normalized = hex.trim().lowercase()
.replace(" ", "")
.removePrefix("0x")
if (normalized.isEmpty()) return ""
if (normalized.isEmpty()) return ByteArray(0)
val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized"
val out = ByteArray(evenHex.length / 2)
@ -101,5 +544,9 @@ internal fun pluginHexToUtf8(hex: String): String {
val part = evenHex.substring(index * 2, index * 2 + 2)
out[index] = part.toInt(16).toByte()
}
return out.decodeToString()
return out
}
internal fun pluginHexToUtf8(hex: String): String {
return pluginHexToByteArray(hex).decodeToString()
}

View file

@ -15,6 +15,16 @@ internal object PluginStorage {
forKey = "${pluginsStateKey}_$profileId",
)
}
fun loadScraperSettings(scraperId: String): String? =
NSUserDefaults.standardUserDefaults.stringForKey("settings_${scraperId}")
fun saveScraperSettings(scraperId: String, payload: String) {
NSUserDefaults.standardUserDefaults.setObject(
payload,
forKey = "settings_${scraperId}",
)
}
}
internal fun currentPluginPlatform(): String = "ios"

View file

@ -9,7 +9,12 @@ import platform.UIKit.UIViewController
interface NuvioPlayerBridge {
fun createPlayerViewController(): UIViewController
fun loadFile(url: String)
fun loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?)
fun loadFileWithAudio(
videoUrl: String,
audioUrl: String?,
headersJson: String?,
subtitlesJson: String? = null
)
fun play()
fun pause()
fun seekTo(positionMs: Long)

View file

@ -41,6 +41,7 @@ actual fun PlatformPlayerSurface(
sourceAudioUrl: String?,
sourceHeaders: Map<String, String>,
sourceResponseHeaders: Map<String, String>,
externalSubtitles: List<com.nuvio.app.features.streams.StreamSubtitle>,
streamType: String?,
useYoutubeChunkedPlayback: Boolean,
modifier: Modifier,
@ -249,12 +250,13 @@ actual fun PlatformPlayerSurface(
}
// Load file and set initial state
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders) {
LaunchedEffect(bridge, sourceUrl, sourceAudioUrl, sourceHeaders, externalSubtitles) {
bridge.applyIosVideoOutputSettings(latestPlayerSettings.value)
bridge.loadFileWithAudio(
sourceUrl,
sourceAudioUrl,
encodePlaybackHeadersForBridge(sourceHeaders),
videoUrl = sourceUrl,
audioUrl = sourceAudioUrl,
headersJson = encodePlaybackHeadersForBridge(sourceHeaders),
subtitlesJson = encodeExternalSubtitlesForBridge(externalSubtitles),
)
if (playWhenReady) {
bridge.play()
@ -400,6 +402,13 @@ private fun Int.toHexByte(): String {
}
}
private fun encodeExternalSubtitlesForBridge(subtitles: List<com.nuvio.app.features.streams.StreamSubtitle>): String? {
if (subtitles.isEmpty()) return null
return runCatching {
Json.encodeToString(subtitles)
}.getOrNull()
}
private fun encodePlaybackHeadersForBridge(headers: Map<String, String>): String? {
val sanitized = sanitizePlaybackHeaders(headers)
if (sanitized.isEmpty()) {

View file

@ -7,6 +7,7 @@ enum {
CC_MD5_DIGEST_LENGTH = 16,
CC_SHA1_DIGEST_LENGTH = 20,
CC_SHA256_DIGEST_LENGTH = 32,
CC_SHA384_DIGEST_LENGTH = 48,
CC_SHA512_DIGEST_LENGTH = 64,
};
@ -21,6 +22,7 @@ typedef enum {
unsigned char *CC_MD5(const void *data, CC_LONG len, unsigned char *md);
unsigned char *CC_SHA1(const void *data, CC_LONG len, unsigned char *md);
unsigned char *CC_SHA256(const void *data, CC_LONG len, unsigned char *md);
unsigned char *CC_SHA384(const void *data, CC_LONG len, unsigned char *md);
unsigned char *CC_SHA512(const void *data, CC_LONG len, unsigned char *md);
void CCHmac(
@ -31,3 +33,158 @@ void CCHmac(
size_t dataLength,
void *macOut
);
typedef uint32_t CCPBKDFAlgorithm;
enum {
kCCPBKDF2 = 2,
};
typedef uint32_t CCPseudoRandomAlgorithm;
enum {
kCCPRFHmacAlgSHA1 = 1,
kCCPRFHmacAlgSHA224 = 2,
kCCPRFHmacAlgSHA256 = 3,
kCCPRFHmacAlgSHA384 = 4,
kCCPRFHmacAlgSHA512 = 5,
};
int CCKeyDerivationPBKDF(
CCPBKDFAlgorithm algorithm,
const void *password,
size_t passwordLen,
const uint8_t *salt,
size_t saltLen,
CCPseudoRandomAlgorithm prf,
uint32_t rounds,
uint8_t *derivedKey,
size_t derivedKeyLen
);
typedef int32_t CCCryptorStatus;
enum {
kCCSuccess = 0,
kCCParamError = -4300,
kCCBufferTooSmall = -4301,
kCCMemoryFailure = -4302,
kCCAlignmentError = -4303,
kCCDecodeError = -4304,
kCCUnimplemented = -4305,
kCCOverflow = -4306,
kCCRNGFailure = -4307,
kCCUnspecifiedError = -4308,
kCCCallSequenceError = -4309,
kCCKeySizeError = -4310,
kCCInvalidKey = -4311,
};
typedef uint32_t CCOperation;
enum {
kCCEncrypt = 0,
kCCDecrypt = 1,
};
typedef uint32_t CCAlgorithm;
enum {
kCCAlgorithmAES128 = 0,
kCCAlgorithmAES = 0,
kCCAlgorithmDES = 1,
kCCAlgorithm3DES = 2,
kCCAlgorithmCAST = 3,
kCCAlgorithmRC4 = 4,
kCCAlgorithmRC2 = 5,
kCCAlgorithmBlowfish = 6,
};
typedef uint32_t CCOptions;
enum {
kCCOptionPKCS7Padding = 1,
kCCOptionECBMode = 2,
};
CCCryptorStatus CCCrypt(
CCOperation op,
CCAlgorithm alg,
CCOptions options,
const void *key,
size_t keyLength,
const void *iv,
const void *dataIn,
size_t dataInLength,
void *dataOut,
size_t dataOutAvailable,
size_t *dataOutMoved
);
typedef uint32_t CCMode;
enum {
kCCModeECB = 1,
kCCModeCBC = 2,
kCCModeCFB = 3,
kCCModeOFB = 4,
kCCModeCFB8 = 5,
kCCModeCTR = 6,
kCCModeF8 = 7,
kCCModeLRW = 8,
kCCModeOFB8 = 9,
kCCModeXTS = 10,
kCCModeRC4 = 11,
kCCModeCFB128 = 12,
kCCModeGCM = 13,
kCCModeCCM = 14,
};
typedef uint32_t CCPadding;
enum {
ccNoPadding = 0,
ccPKCS7Padding = 1,
};
typedef uint32_t CCModeOptions;
typedef struct _CCCryptor *CCCryptorRef;
CCCryptorStatus CCCryptorCreateWithMode(
CCOperation op,
CCMode mode,
CCAlgorithm alg,
CCPadding padding,
const void *iv,
const void *key,
size_t keyLength,
const void *tweak,
size_t tweakLength,
int numRounds,
CCModeOptions options,
CCCryptorRef *cryptorRef
);
CCCryptorStatus CCCryptorGCMAddAAD(
CCCryptorRef cryptorRef,
const void *aData,
size_t aDataLen
);
CCCryptorStatus CCCryptorGCMEncrypt(
CCCryptorRef cryptorRef,
const void *dataIn,
size_t dataInLength,
void *dataOut
);
CCCryptorStatus CCCryptorGCMDecrypt(
CCCryptorRef cryptorRef,
const void *dataIn,
size_t dataInLength,
void *dataOut
);
CCCryptorStatus CCCryptorGCMFinal(
CCCryptorRef cryptorRef,
void *tag,
size_t *tagLength
);
CCCryptorStatus CCCryptorRelease(
CCCryptorRef cryptorRef
);

View file

@ -16,13 +16,34 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
}
func loadFile(url: String) { playerVC?.loadFile(url) }
func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?) {
func loadFileWithAudio(videoUrl: String, audioUrl: String?, headersJson: String?, subtitlesJson: String?) {
playerVC?.loadFile(
videoUrl,
audioUrl: audioUrl,
requestHeaders: parseRequestHeaders(headersJson)
requestHeaders: parseRequestHeaders(headersJson),
subtitles: parseSubtitles(subtitlesJson)
)
}
private func parseSubtitles(_ json: String?) -> [PluginSubtitle] {
guard
let json,
let data = json.data(using: .utf8),
let raw = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
return []
}
return raw.compactMap { dict in
guard let url = dict["url"] as? String else { return nil }
return PluginSubtitle(
url: url,
language: dict["language"] as? String ?? "Unknown",
name: dict["name"] as? String,
headers: dict["headers"] as? [String: String]
)
}
}
func play() { playerVC?.playPlayback() }
func pause() { playerVC?.pausePlayback() }
func seekTo(positionMs: Int64) { playerVC?.seekToMs(positionMs) }
@ -173,6 +194,13 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
}
}
struct PluginSubtitle {
let url: String
let language: String
let name: String?
let headers: [String: String]?
}
// MARK: - Track Info
struct TrackInfo {
@ -188,6 +216,7 @@ private struct PendingLoadRequest {
let urlString: String
let audioUrl: String?
let requestHeaders: [String: String]
let subtitles: [PluginSubtitle]
let queuedAtUptime: TimeInterval
}
@ -376,11 +405,12 @@ final class MPVPlayerViewController: UIViewController {
// MARK: - Playback API
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) {
func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:], subtitles: [PluginSubtitle] = []) {
let request = PendingLoadRequest(
urlString: urlString,
audioUrl: audioUrl,
requestHeaders: requestHeaders,
subtitles: subtitles,
queuedAtUptime: ProcessInfo.processInfo.systemUptime
)
@ -428,6 +458,12 @@ final class MPVPlayerViewController: UIViewController {
self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false)
}
}
for subtitle in request.subtitles {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.addSubtitle(subtitle, mode: "auto")
}
}
}
private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool {
@ -572,6 +608,26 @@ final class MPVPlayerViewController: UIViewController {
command("sub-add", args: [url, "select"])
}
private func addSubtitle(_ subtitle: PluginSubtitle, mode: String) {
guard mpv != nil else { return }
let subtitleHeaders = sanitizeRequestHeaders(subtitle.headers ?? [:])
let previousHeaders = activeRequestHeaders
if !subtitleHeaders.isEmpty {
applyRequestHeaders(previousHeaders.merging(subtitleHeaders) { _, subtitleValue in subtitleValue })
}
command(
"sub-add",
args: [subtitle.url, mode, subtitle.name ?? subtitle.language, subtitle.language],
checkForErrors: false
)
if !subtitleHeaders.isEmpty {
applyRequestHeaders(previousHeaders)
}
}
func removeExternalSubtitles() {
guard mpv != nil else { return }
let count = getInt("track-list/count")