mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-26 19:12:54 +00:00
cleanup
This commit is contained in:
parent
4f27afc174
commit
4e57a8eb72
8 changed files with 9 additions and 2069 deletions
|
|
@ -170,12 +170,12 @@ kotlin {
|
||||||
implementation(libs.supabase.postgrest)
|
implementation(libs.supabase.postgrest)
|
||||||
implementation(libs.supabase.auth)
|
implementation(libs.supabase.auth)
|
||||||
implementation(libs.supabase.functions)
|
implementation(libs.supabase.functions)
|
||||||
implementation(libs.quickjs.kt)
|
|
||||||
implementation(libs.ksoup)
|
|
||||||
implementation(libs.reorderable)
|
implementation(libs.reorderable)
|
||||||
}
|
}
|
||||||
iosMain.dependencies {
|
iosMain.dependencies {
|
||||||
implementation(libs.ktor.client.darwin)
|
implementation(libs.ktor.client.darwin)
|
||||||
|
implementation(libs.quickjs.kt)
|
||||||
|
implementation(libs.ksoup)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
|
@ -183,6 +183,13 @@ kotlin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
afterEvaluate {
|
||||||
|
dependencies {
|
||||||
|
add("androidFullImplementation", libs.quickjs.kt)
|
||||||
|
add("androidFullImplementation", libs.ksoup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
debugImplementation(libs.compose.uiTooling)
|
debugImplementation(libs.compose.uiTooling)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
package com.nuvio.app.features.plugins
|
|
||||||
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
|
||||||
|
|
||||||
internal expect fun pluginDigestHex(algorithm: String, data: String): String
|
|
||||||
|
|
||||||
internal expect fun pluginHmacHex(algorithm: String, key: String, data: String): String
|
|
||||||
|
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
|
||||||
internal fun pluginBase64Encode(data: String): String =
|
|
||||||
Base64.encode(data.encodeToByteArray())
|
|
||||||
|
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
|
||||||
internal fun pluginBase64Decode(data: String): String {
|
|
||||||
val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "")
|
|
||||||
val decoded = Base64.decode(normalized)
|
|
||||||
return decoded.decodeToString()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun pluginUtf8ToHex(value: String): String =
|
|
||||||
value.encodeToByteArray().joinToString(separator = "") { byte ->
|
|
||||||
byte.toUByte().toString(16).padStart(2, '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun pluginHexToUtf8(hex: String): String {
|
|
||||||
val normalized = hex.trim().lowercase()
|
|
||||||
.replace(" ", "")
|
|
||||||
.removePrefix("0x")
|
|
||||||
if (normalized.isEmpty()) return ""
|
|
||||||
|
|
||||||
val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized"
|
|
||||||
val out = ByteArray(evenHex.length / 2)
|
|
||||||
for (index in out.indices) {
|
|
||||||
val part = evenHex.substring(index * 2, index * 2 + 2)
|
|
||||||
out[index] = part.toInt(16).toByte()
|
|
||||||
}
|
|
||||||
return out.decodeToString()
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
package com.nuvio.app.features.plugins
|
|
||||||
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
|
|
||||||
internal object PluginManifestParser {
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun parse(payload: String): PluginManifest {
|
|
||||||
val manifest = json.decodeFromString<PluginManifest>(payload)
|
|
||||||
require(manifest.name.isNotBlank()) { "Manifest name is missing." }
|
|
||||||
require(manifest.version.isNotBlank()) { "Manifest version is missing." }
|
|
||||||
require(manifest.scrapers.isNotEmpty()) { "Manifest has no providers." }
|
|
||||||
return manifest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
package com.nuvio.app.features.plugins
|
|
||||||
|
|
||||||
internal expect object PluginStorage {
|
|
||||||
fun loadState(profileId: Int): String?
|
|
||||||
fun saveState(profileId: Int, payload: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal expect fun currentPluginPlatform(): String
|
|
||||||
|
|
||||||
internal expect fun currentEpochMillis(): Long
|
|
||||||
|
|
@ -1,566 +0,0 @@
|
||||||
package com.nuvio.app.features.plugins
|
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
import com.nuvio.app.core.network.SupabaseProvider
|
|
||||||
import com.nuvio.app.features.addons.httpGetText
|
|
||||||
import com.nuvio.app.features.profiles.ProfileRepository
|
|
||||||
import io.github.jan.supabase.postgrest.postgrest
|
|
||||||
import io.github.jan.supabase.postgrest.query.Order
|
|
||||||
import io.github.jan.supabase.postgrest.rpc
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.serialization.SerialName
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
import kotlinx.serialization.encodeToString
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
|
||||||
import kotlinx.serialization.json.encodeToJsonElement
|
|
||||||
import kotlinx.serialization.json.put
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class PluginRow(
|
|
||||||
val url: String,
|
|
||||||
val name: String? = null,
|
|
||||||
val enabled: Boolean = true,
|
|
||||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
private data class PluginPushItem(
|
|
||||||
val url: String,
|
|
||||||
val name: String = "",
|
|
||||||
val enabled: Boolean = true,
|
|
||||||
@SerialName("sort_order") val sortOrder: Int = 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
actual object PluginRepository {
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
|
||||||
private val log = Logger.withTag("PluginRepository")
|
|
||||||
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(PluginsUiState())
|
|
||||||
actual val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
|
|
||||||
|
|
||||||
private var initialized = false
|
|
||||||
private var pulledFromServer = false
|
|
||||||
private var currentProfileId = 1
|
|
||||||
private val activeRefreshJobs = mutableMapOf<String, Job>()
|
|
||||||
|
|
||||||
actual fun initialize() {
|
|
||||||
val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId)
|
|
||||||
val shouldRefreshStoredRepos = !initialized || currentProfileId != effectiveProfileId
|
|
||||||
ensureStateLoadedForProfile(effectiveProfileId)
|
|
||||||
if (!shouldRefreshStoredRepos) return
|
|
||||||
|
|
||||||
_uiState.value.repositories.forEach { repo ->
|
|
||||||
refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun onProfileChanged(profileId: Int) {
|
|
||||||
val effectiveProfileId = resolveEffectiveProfileId(profileId)
|
|
||||||
if (effectiveProfileId == currentProfileId && initialized) return
|
|
||||||
|
|
||||||
cancelActiveRefreshes()
|
|
||||||
currentProfileId = effectiveProfileId
|
|
||||||
initialized = false
|
|
||||||
pulledFromServer = false
|
|
||||||
_uiState.value = PluginsUiState()
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun clearLocalState() {
|
|
||||||
cancelActiveRefreshes()
|
|
||||||
currentProfileId = 1
|
|
||||||
initialized = false
|
|
||||||
pulledFromServer = false
|
|
||||||
_uiState.value = PluginsUiState()
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun pullFromServer(profileId: Int) {
|
|
||||||
val effectiveProfileId = resolveEffectiveProfileId(profileId)
|
|
||||||
ensureStateLoadedForProfile(effectiveProfileId)
|
|
||||||
runCatching {
|
|
||||||
val rows = SupabaseProvider.client.postgrest
|
|
||||||
.from("plugins")
|
|
||||||
.select {
|
|
||||||
filter { eq("profile_id", currentProfileId) }
|
|
||||||
order("sort_order", Order.ASCENDING)
|
|
||||||
}
|
|
||||||
.decodeList<PluginRow>()
|
|
||||||
|
|
||||||
val urls = dedupeManifestUrls(rows.map { it.url })
|
|
||||||
if (urls.isEmpty() && !pulledFromServer) {
|
|
||||||
val localUrls = _uiState.value.repositories.map { it.manifestUrl }
|
|
||||||
if (localUrls.isNotEmpty()) {
|
|
||||||
initialize()
|
|
||||||
pulledFromServer = true
|
|
||||||
pushToServer()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val existingReposByUrl = _uiState.value.repositories.associateBy { it.manifestUrl }
|
|
||||||
val nextRepos = urls.map { url ->
|
|
||||||
existingReposByUrl[url]?.copy(isRefreshing = true, errorMessage = null)
|
|
||||||
?: PluginRepositoryItem(
|
|
||||||
manifestUrl = url,
|
|
||||||
name = url.substringBefore("?").substringAfterLast('/'),
|
|
||||||
isRefreshing = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val nextScrapers = _uiState.value.scrapers.filter { scraper ->
|
|
||||||
urls.contains(scraper.repositoryUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.value = PluginsUiState(
|
|
||||||
pluginsEnabled = _uiState.value.pluginsEnabled,
|
|
||||||
groupStreamsByRepository = _uiState.value.groupStreamsByRepository,
|
|
||||||
repositories = nextRepos,
|
|
||||||
scrapers = nextScrapers,
|
|
||||||
)
|
|
||||||
persist()
|
|
||||||
|
|
||||||
urls.forEach { url ->
|
|
||||||
refreshRepository(url, pushAfterRefresh = false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pulledFromServer = true
|
|
||||||
initialized = true
|
|
||||||
}.onFailure { error ->
|
|
||||||
log.e(error) { "pullFromServer failed" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult {
|
|
||||||
initialize()
|
|
||||||
val manifestUrl = try {
|
|
||||||
normalizeManifestUrl(rawUrl)
|
|
||||||
} catch (error: IllegalArgumentException) {
|
|
||||||
return AddPluginRepositoryResult.Error(error.message ?: "Enter a valid plugin URL")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_uiState.value.repositories.any { it.manifestUrl == manifestUrl }) {
|
|
||||||
return AddPluginRepositoryResult.Error("That plugin repository is already installed.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return try {
|
|
||||||
val previousById = _uiState.value.scrapers.associateBy { it.id }
|
|
||||||
val (repo, scrapers) = fetchRepositoryData(
|
|
||||||
manifestUrl = manifestUrl,
|
|
||||||
previousScrapers = previousById,
|
|
||||||
)
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
repositories = state.repositories + repo,
|
|
||||||
scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl } + scrapers,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
persist()
|
|
||||||
pushToServer()
|
|
||||||
AddPluginRepositoryResult.Success(repo)
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
AddPluginRepositoryResult.Error(error.message ?: "Unable to install plugin repository")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun removeRepository(manifestUrl: String) {
|
|
||||||
initialize()
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
repositories = state.repositories.filterNot { it.manifestUrl == manifestUrl },
|
|
||||||
scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
persist()
|
|
||||||
pushToServer()
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun refreshAll() {
|
|
||||||
initialize()
|
|
||||||
_uiState.value.repositories.forEach { repo ->
|
|
||||||
refreshRepositoryInternal(repo.manifestUrl, pushAfterRefresh = false, ensureInitialized = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean) {
|
|
||||||
refreshRepositoryInternal(manifestUrl, pushAfterRefresh, ensureInitialized = true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun refreshRepositoryInternal(
|
|
||||||
manifestUrl: String,
|
|
||||||
pushAfterRefresh: Boolean,
|
|
||||||
ensureInitialized: Boolean,
|
|
||||||
) {
|
|
||||||
if (ensureInitialized) {
|
|
||||||
initialize()
|
|
||||||
}
|
|
||||||
val existingJob = activeRefreshJobs[manifestUrl]
|
|
||||||
if (existingJob?.isActive == true) return
|
|
||||||
|
|
||||||
markRefreshing(manifestUrl)
|
|
||||||
var refreshJob: Job? = null
|
|
||||||
refreshJob = scope.launch {
|
|
||||||
try {
|
|
||||||
val result = runCatching {
|
|
||||||
val previous = _uiState.value.scrapers.associateBy { it.id }
|
|
||||||
fetchRepositoryData(manifestUrl, previous)
|
|
||||||
}
|
|
||||||
|
|
||||||
_uiState.update { state ->
|
|
||||||
result.fold(
|
|
||||||
onSuccess = { (repo, scrapers) ->
|
|
||||||
val updatedRepos = state.repositories.map { existing ->
|
|
||||||
if (existing.manifestUrl == manifestUrl) repo else existing
|
|
||||||
}
|
|
||||||
state.copy(
|
|
||||||
repositories = updatedRepos,
|
|
||||||
scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl } + scrapers,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onFailure = { error ->
|
|
||||||
state.copy(
|
|
||||||
repositories = state.repositories.map { existing ->
|
|
||||||
if (existing.manifestUrl == manifestUrl) {
|
|
||||||
existing.copy(
|
|
||||||
isRefreshing = false,
|
|
||||||
errorMessage = error.message ?: "Unable to refresh repository",
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
existing
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
persist()
|
|
||||||
if (pushAfterRefresh) {
|
|
||||||
pushToServer()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (activeRefreshJobs[manifestUrl] === refreshJob) {
|
|
||||||
activeRefreshJobs.remove(manifestUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeRefreshJobs[manifestUrl] = refreshJob
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun toggleScraper(scraperId: String, enabled: Boolean) {
|
|
||||||
initialize()
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
scrapers = state.scrapers.map { scraper ->
|
|
||||||
if (scraper.id == scraperId) {
|
|
||||||
scraper.copy(enabled = if (scraper.manifestEnabled) enabled else false)
|
|
||||||
} else {
|
|
||||||
scraper
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun setPluginsEnabled(enabled: Boolean) {
|
|
||||||
initialize()
|
|
||||||
_uiState.update { it.copy(pluginsEnabled = enabled) }
|
|
||||||
persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun setGroupStreamsByRepository(enabled: Boolean) {
|
|
||||||
initialize()
|
|
||||||
_uiState.update { it.copy(groupStreamsByRepository = enabled) }
|
|
||||||
persist()
|
|
||||||
}
|
|
||||||
|
|
||||||
actual fun getEnabledScrapersForType(type: String): List<PluginScraper> {
|
|
||||||
initialize()
|
|
||||||
if (!_uiState.value.pluginsEnabled) return emptyList()
|
|
||||||
return _uiState.value.scrapers.filter { scraper ->
|
|
||||||
scraper.enabled && scraper.supportsType(type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> {
|
|
||||||
initialize()
|
|
||||||
val scraper = _uiState.value.scrapers.find { it.id == scraperId }
|
|
||||||
?: return Result.failure(IllegalArgumentException("Provider not found"))
|
|
||||||
|
|
||||||
val mediaType = if (scraper.supportsType("movie")) "movie" else "tv"
|
|
||||||
val season = if (mediaType == "tv") 1 else null
|
|
||||||
val episode = if (mediaType == "tv") 1 else null
|
|
||||||
return executeScraper(
|
|
||||||
scraper = scraper,
|
|
||||||
tmdbId = "603",
|
|
||||||
mediaType = mediaType,
|
|
||||||
season = season,
|
|
||||||
episode = episode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
actual suspend fun executeScraper(
|
|
||||||
scraper: PluginScraper,
|
|
||||||
tmdbId: String,
|
|
||||||
mediaType: String,
|
|
||||||
season: Int?,
|
|
||||||
episode: Int?,
|
|
||||||
): Result<List<PluginRuntimeResult>> {
|
|
||||||
return runCatching {
|
|
||||||
PluginRuntime.executePlugin(
|
|
||||||
code = scraper.code,
|
|
||||||
tmdbId = tmdbId,
|
|
||||||
mediaType = normalizePluginType(mediaType),
|
|
||||||
season = season,
|
|
||||||
episode = episode,
|
|
||||||
scraperId = scraper.id,
|
|
||||||
scraperSettings = emptyMap(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun fetchRepositoryData(
|
|
||||||
manifestUrl: String,
|
|
||||||
previousScrapers: Map<String, PluginScraper>,
|
|
||||||
): Pair<PluginRepositoryItem, List<PluginScraper>> = withContext(Dispatchers.Default) {
|
|
||||||
val payload = httpGetText(manifestUrl)
|
|
||||||
val manifest = PluginManifestParser.parse(payload)
|
|
||||||
val baseUrl = manifestUrl.substringBefore("?").removeSuffix("/manifest.json")
|
|
||||||
|
|
||||||
val scrapers = manifest.scrapers
|
|
||||||
.filter { scraper -> scraper.isSupportedOnCurrentPlatform() }
|
|
||||||
.mapNotNull { info ->
|
|
||||||
val codeUrl = if (info.filename.startsWith("http://") || info.filename.startsWith("https://")) {
|
|
||||||
info.filename
|
|
||||||
} else {
|
|
||||||
"$baseUrl/${info.filename.trimStart('/')}"
|
|
||||||
}
|
|
||||||
runCatching {
|
|
||||||
val code = httpGetText(codeUrl)
|
|
||||||
val scraperId = "${manifestUrl.lowercase()}:${info.id}"
|
|
||||||
val previous = previousScrapers[scraperId]
|
|
||||||
val enabled = when {
|
|
||||||
!info.enabled -> false
|
|
||||||
previous != null -> previous.enabled
|
|
||||||
else -> info.enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
PluginScraper(
|
|
||||||
id = scraperId,
|
|
||||||
repositoryUrl = manifestUrl,
|
|
||||||
name = info.name,
|
|
||||||
description = info.description.orEmpty(),
|
|
||||||
version = info.version,
|
|
||||||
filename = info.filename,
|
|
||||||
supportedTypes = info.supportedTypes,
|
|
||||||
enabled = enabled,
|
|
||||||
manifestEnabled = info.enabled,
|
|
||||||
logo = info.logo,
|
|
||||||
contentLanguage = info.contentLanguage ?: emptyList(),
|
|
||||||
formats = info.formats ?: info.supportedFormats,
|
|
||||||
code = code,
|
|
||||||
)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
val repo = PluginRepositoryItem(
|
|
||||||
manifestUrl = manifestUrl,
|
|
||||||
name = manifest.name,
|
|
||||||
description = manifest.description,
|
|
||||||
version = manifest.version,
|
|
||||||
scraperCount = scrapers.size,
|
|
||||||
lastUpdated = currentEpochMillis(),
|
|
||||||
isRefreshing = false,
|
|
||||||
errorMessage = null,
|
|
||||||
)
|
|
||||||
repo to scrapers
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun PluginManifestScraper.isSupportedOnCurrentPlatform(): Boolean {
|
|
||||||
val platform = currentPluginPlatform().lowercase()
|
|
||||||
val supported = supportedPlatforms?.map { it.lowercase() }?.toSet().orEmpty()
|
|
||||||
val disabled = disabledPlatforms?.map { it.lowercase() }?.toSet().orEmpty()
|
|
||||||
if (supported.isNotEmpty() && platform !in supported) return false
|
|
||||||
if (platform in disabled) return false
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun markRefreshing(manifestUrl: String) {
|
|
||||||
_uiState.update { state ->
|
|
||||||
state.copy(
|
|
||||||
repositories = state.repositories.map { repo ->
|
|
||||||
if (repo.manifestUrl == manifestUrl) {
|
|
||||||
repo.copy(isRefreshing = true, errorMessage = null)
|
|
||||||
} else {
|
|
||||||
repo
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pushToServer() {
|
|
||||||
scope.launch {
|
|
||||||
runCatching {
|
|
||||||
val repos = _uiState.value.repositories.mapIndexed { index, repo ->
|
|
||||||
PluginPushItem(
|
|
||||||
url = repo.manifestUrl,
|
|
||||||
name = repo.name,
|
|
||||||
enabled = true,
|
|
||||||
sortOrder = index,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val params = buildJsonObject {
|
|
||||||
put("p_profile_id", currentProfileId)
|
|
||||||
put("p_plugins", json.encodeToJsonElement(repos))
|
|
||||||
}
|
|
||||||
SupabaseProvider.client.postgrest.rpc("sync_push_plugins", params)
|
|
||||||
}.onFailure { error ->
|
|
||||||
log.e(error) { "pushToServer failed" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun persist() {
|
|
||||||
val state = _uiState.value
|
|
||||||
val payload = StoredPluginsState(
|
|
||||||
pluginsEnabled = state.pluginsEnabled,
|
|
||||||
groupStreamsByRepository = state.groupStreamsByRepository,
|
|
||||||
repositories = state.repositories.map { repo ->
|
|
||||||
StoredPluginRepository(
|
|
||||||
manifestUrl = repo.manifestUrl,
|
|
||||||
name = repo.name,
|
|
||||||
description = repo.description,
|
|
||||||
version = repo.version,
|
|
||||||
scraperCount = repo.scraperCount,
|
|
||||||
lastUpdated = repo.lastUpdated,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scrapers = state.scrapers.map { scraper ->
|
|
||||||
StoredPluginScraper(
|
|
||||||
id = scraper.id,
|
|
||||||
repositoryUrl = scraper.repositoryUrl,
|
|
||||||
name = scraper.name,
|
|
||||||
description = scraper.description,
|
|
||||||
version = scraper.version,
|
|
||||||
filename = scraper.filename,
|
|
||||||
supportedTypes = scraper.supportedTypes,
|
|
||||||
enabled = scraper.enabled,
|
|
||||||
manifestEnabled = scraper.manifestEnabled,
|
|
||||||
logo = scraper.logo,
|
|
||||||
contentLanguage = scraper.contentLanguage,
|
|
||||||
formats = scraper.formats,
|
|
||||||
code = scraper.code,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
PluginStorage.saveState(currentProfileId, json.encodeToString(payload))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadStoredState(profileId: Int): StoredPluginsState? {
|
|
||||||
val raw = PluginStorage.loadState(profileId)?.trim().orEmpty()
|
|
||||||
if (raw.isBlank()) return null
|
|
||||||
return runCatching {
|
|
||||||
json.decodeFromString<StoredPluginsState>(raw)
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cancelActiveRefreshes() {
|
|
||||||
activeRefreshJobs.values.forEach(Job::cancel)
|
|
||||||
activeRefreshJobs.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun ensureStateLoadedForProfile(profileId: Int) {
|
|
||||||
if (initialized && currentProfileId == profileId) return
|
|
||||||
|
|
||||||
if (currentProfileId != profileId) {
|
|
||||||
cancelActiveRefreshes()
|
|
||||||
pulledFromServer = false
|
|
||||||
}
|
|
||||||
|
|
||||||
currentProfileId = profileId
|
|
||||||
_uiState.value = loadStateAsUiState(profileId)
|
|
||||||
initialized = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadStateAsUiState(profileId: Int): PluginsUiState {
|
|
||||||
val stored = loadStoredState(profileId)
|
|
||||||
return PluginsUiState(
|
|
||||||
pluginsEnabled = stored?.pluginsEnabled ?: true,
|
|
||||||
groupStreamsByRepository = stored?.groupStreamsByRepository ?: false,
|
|
||||||
repositories = stored?.repositories
|
|
||||||
?.map {
|
|
||||||
PluginRepositoryItem(
|
|
||||||
manifestUrl = it.manifestUrl,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
version = it.version,
|
|
||||||
scraperCount = it.scraperCount,
|
|
||||||
lastUpdated = it.lastUpdated,
|
|
||||||
isRefreshing = false,
|
|
||||||
errorMessage = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
?: emptyList(),
|
|
||||||
scrapers = stored?.scrapers
|
|
||||||
?.map {
|
|
||||||
PluginScraper(
|
|
||||||
id = it.id,
|
|
||||||
repositoryUrl = it.repositoryUrl,
|
|
||||||
name = it.name,
|
|
||||||
description = it.description,
|
|
||||||
version = it.version,
|
|
||||||
filename = it.filename,
|
|
||||||
supportedTypes = it.supportedTypes,
|
|
||||||
enabled = it.enabled,
|
|
||||||
manifestEnabled = it.manifestEnabled,
|
|
||||||
logo = it.logo,
|
|
||||||
contentLanguage = it.contentLanguage,
|
|
||||||
formats = it.formats,
|
|
||||||
code = it.code,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
?: emptyList(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun dedupeManifestUrls(urls: List<String>): List<String> =
|
|
||||||
urls.map(::ensureManifestSuffix).distinct()
|
|
||||||
|
|
||||||
private fun ensureManifestSuffix(url: String): String {
|
|
||||||
val path = url.substringBefore("?").trimEnd('/')
|
|
||||||
val query = url.substringAfter("?", "")
|
|
||||||
val withSuffix = if (path.endsWith("/manifest.json")) path else "$path/manifest.json"
|
|
||||||
return if (query.isEmpty()) withSuffix else "$withSuffix?$query"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun normalizeManifestUrl(rawUrl: String): String {
|
|
||||||
val trimmed = rawUrl.trim()
|
|
||||||
require(trimmed.isNotEmpty()) { "Enter a plugin repository URL." }
|
|
||||||
|
|
||||||
val normalizedScheme = when {
|
|
||||||
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed
|
|
||||||
else -> "https://$trimmed"
|
|
||||||
}
|
|
||||||
|
|
||||||
val withoutFragment = normalizedScheme.substringBefore("#")
|
|
||||||
val query = withoutFragment.substringAfter("?", "")
|
|
||||||
val path = withoutFragment.substringBefore("?").trimEnd('/')
|
|
||||||
val manifestPath = if (path.endsWith("/manifest.json")) path else "$path/manifest.json"
|
|
||||||
return if (query.isEmpty()) manifestPath else "$manifestPath?$query"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun resolveEffectiveProfileId(profileId: Int): Int {
|
|
||||||
val active = ProfileRepository.state.value.activeProfile
|
|
||||||
return if (active != null && !active.id.isBlank() && active.usesPrimaryPlugins) 1 else profileId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,987 +0,0 @@
|
||||||
package com.nuvio.app.features.plugins
|
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
|
||||||
import com.dokar.quickjs.binding.define
|
|
||||||
import com.dokar.quickjs.binding.function
|
|
||||||
import com.dokar.quickjs.quickJs
|
|
||||||
import com.fleeksoft.ksoup.Ksoup
|
|
||||||
import com.fleeksoft.ksoup.nodes.Document
|
|
||||||
import com.fleeksoft.ksoup.nodes.Element
|
|
||||||
import com.fleeksoft.ksoup.select.Elements
|
|
||||||
import com.nuvio.app.features.addons.httpRequestRaw
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import kotlinx.coroutines.withTimeout
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonArray
|
|
||||||
import kotlinx.serialization.json.JsonElement
|
|
||||||
import kotlinx.serialization.json.JsonNull
|
|
||||||
import kotlinx.serialization.json.JsonObject
|
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
|
||||||
import kotlinx.serialization.json.contentOrNull
|
|
||||||
import kotlinx.serialization.json.intOrNull
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import kotlin.random.Random
|
|
||||||
|
|
||||||
private const val PLUGIN_TIMEOUT_MS = 60_000L
|
|
||||||
private const val MAX_FETCH_BODY_CHARS = 256 * 1024
|
|
||||||
private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024
|
|
||||||
private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]"
|
|
||||||
|
|
||||||
internal object PluginRuntime {
|
|
||||||
private val log = Logger.withTag("PluginRuntime")
|
|
||||||
private val json = Json {
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""")
|
|
||||||
|
|
||||||
suspend fun executePlugin(
|
|
||||||
code: String,
|
|
||||||
tmdbId: String,
|
|
||||||
mediaType: String,
|
|
||||||
season: Int?,
|
|
||||||
episode: Int?,
|
|
||||||
scraperId: String,
|
|
||||||
scraperSettings: Map<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() ?: ""
|
|
||||||
try {
|
|
||||||
performNativeFetch(url, method, headersJson, body)
|
|
||||||
} catch (t: Throwable) {
|
|
||||||
log.e(t) { "Fetch bridge error for $method $url" }
|
|
||||||
JsonObject(
|
|
||||||
mapOf(
|
|
||||||
"ok" to JsonPrimitive(false),
|
|
||||||
"status" to JsonPrimitive(0),
|
|
||||||
"statusText" to JsonPrimitive(t.message ?: "Fetch failed"),
|
|
||||||
"url" to JsonPrimitive(url),
|
|
||||||
"body" to JsonPrimitive(""),
|
|
||||||
"headers" to JsonObject(emptyMap()),
|
|
||||||
),
|
|
||||||
).toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__crypto_digest_hex") { args ->
|
|
||||||
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
|
|
||||||
val data = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
runCatching {
|
|
||||||
pluginDigestHex(algorithm, data)
|
|
||||||
}.getOrDefault("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__crypto_hmac_hex") { args ->
|
|
||||||
val algorithm = args.getOrNull(0)?.toString() ?: "SHA256"
|
|
||||||
val key = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
val data = args.getOrNull(2)?.toString() ?: ""
|
|
||||||
runCatching {
|
|
||||||
pluginHmacHex(algorithm, key, data)
|
|
||||||
}.getOrDefault("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__crypto_base64_encode") { args ->
|
|
||||||
val data = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
runCatching {
|
|
||||||
pluginBase64Encode(data)
|
|
||||||
}.getOrDefault("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__crypto_base64_decode") { args ->
|
|
||||||
val data = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
runCatching {
|
|
||||||
pluginBase64Decode(data)
|
|
||||||
}.getOrDefault("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__crypto_utf8_to_hex") { args ->
|
|
||||||
val data = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
runCatching {
|
|
||||||
pluginUtf8ToHex(data)
|
|
||||||
}.getOrDefault("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__crypto_hex_to_utf8") { args ->
|
|
||||||
val data = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
runCatching {
|
|
||||||
pluginHexToUtf8(data)
|
|
||||||
}.getOrDefault("")
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__parse_url") { args ->
|
|
||||||
parseUrl(args.getOrNull(0)?.toString() ?: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_load") { args ->
|
|
||||||
val html = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
val docId = "doc_${idCounter++}_${Random.nextInt(0, Int.MAX_VALUE)}"
|
|
||||||
documentCache[docId] = Ksoup.parse(html)
|
|
||||||
docId
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_select") { args ->
|
|
||||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
var selector = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
val doc = documentCache[docId] ?: return@function "[]"
|
|
||||||
try {
|
|
||||||
selector = selector.replace(containsRegex, ":contains($1)")
|
|
||||||
val elements = if (selector.isEmpty()) Elements() else doc.select(selector)
|
|
||||||
val ids = elements.mapIndexed { index, el ->
|
|
||||||
val id = "$docId:$index:${el.hashCode()}"
|
|
||||||
elementCache[id] = el
|
|
||||||
id
|
|
||||||
}
|
|
||||||
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
|
|
||||||
} catch (_: Exception) {
|
|
||||||
"[]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_find") { args ->
|
|
||||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
var selector = args.getOrNull(2)?.toString() ?: ""
|
|
||||||
val element = elementCache[elementId] ?: return@function "[]"
|
|
||||||
try {
|
|
||||||
selector = selector.replace(containsRegex, ":contains($1)")
|
|
||||||
val elements = element.select(selector)
|
|
||||||
val ids = elements.mapIndexed { index, el ->
|
|
||||||
val id = "$docId:find:$index:${el.hashCode()}"
|
|
||||||
elementCache[id] = el
|
|
||||||
id
|
|
||||||
}
|
|
||||||
"[" + ids.joinToString(",") { "\"${it.replace("\"", "\\\"")}\"" } + "]"
|
|
||||||
} catch (_: Exception) {
|
|
||||||
"[]"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_text") { args ->
|
|
||||||
val elementIds = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
elementIds.split(",")
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.mapNotNull { elementCache[it]?.text() }
|
|
||||||
.joinToString(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_html") { args ->
|
|
||||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
if (elementId.isEmpty()) {
|
|
||||||
documentCache[docId]?.html() ?: ""
|
|
||||||
} else {
|
|
||||||
elementCache[elementId]?.html() ?: ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_inner_html") { args ->
|
|
||||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
elementCache[elementId]?.html() ?: ""
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_attr") { args ->
|
|
||||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
val attrName = args.getOrNull(2)?.toString() ?: ""
|
|
||||||
val value = elementCache[elementId]?.attr(attrName)
|
|
||||||
if (value.isNullOrEmpty()) "__UNDEFINED__" else value
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_next") { args ->
|
|
||||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
val element = elementCache[elementId] ?: return@function "__NONE__"
|
|
||||||
val next = element.nextElementSibling() ?: return@function "__NONE__"
|
|
||||||
val nextId = "$docId:next:${next.hashCode()}"
|
|
||||||
elementCache[nextId] = next
|
|
||||||
nextId
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__cheerio_prev") { args ->
|
|
||||||
val docId = args.getOrNull(0)?.toString() ?: ""
|
|
||||||
val elementId = args.getOrNull(1)?.toString() ?: ""
|
|
||||||
val element = elementCache[elementId] ?: return@function "__NONE__"
|
|
||||||
val prev = element.previousElementSibling() ?: return@function "__NONE__"
|
|
||||||
val prevId = "$docId:prev:${prev.hashCode()}"
|
|
||||||
elementCache[prevId] = prev
|
|
||||||
prevId
|
|
||||||
}
|
|
||||||
|
|
||||||
function("__capture_result") { args ->
|
|
||||||
resultJson = args.getOrNull(0)?.toString() ?: "[]"
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
val settingsJson = toJsonElement(scraperSettings).toString()
|
|
||||||
val polyfillCode = buildPolyfillCode(scraperId, settingsJson)
|
|
||||||
evaluate<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,
|
|
||||||
): String {
|
|
||||||
return try {
|
|
||||||
val headers = parseHeaders(headersJson).toMutableMap()
|
|
||||||
if (!headers.containsKey("User-Agent")) {
|
|
||||||
headers["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
|
||||||
}
|
|
||||||
|
|
||||||
val response = runBlocking {
|
|
||||||
httpRequestRaw(
|
|
||||||
method = method,
|
|
||||||
url = url,
|
|
||||||
headers = headers,
|
|
||||||
body = body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val responseHeaders = response.headers.mapValues { (_, value) ->
|
|
||||||
truncateString(value, MAX_FETCH_HEADER_VALUE_CHARS)
|
|
||||||
}
|
|
||||||
val result = JsonObject(
|
|
||||||
mapOf(
|
|
||||||
"ok" to JsonPrimitive(response.status in 200..299),
|
|
||||||
"status" to JsonPrimitive(response.status),
|
|
||||||
"statusText" to JsonPrimitive(response.statusText),
|
|
||||||
"url" to JsonPrimitive(response.url),
|
|
||||||
"body" to JsonPrimitive(truncateString(response.body, MAX_FETCH_BODY_CHARS)),
|
|
||||||
"headers" to JsonObject(responseHeaders.mapValues { JsonPrimitive(it.value) }),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
result.toString()
|
|
||||||
} catch (error: Throwable) {
|
|
||||||
log.e(error) { "Fetch error for $method $url" }
|
|
||||||
JsonObject(
|
|
||||||
mapOf(
|
|
||||||
"ok" to JsonPrimitive(false),
|
|
||||||
"status" to JsonPrimitive(0),
|
|
||||||
"statusText" to JsonPrimitive(error.message ?: "Fetch failed"),
|
|
||||||
"url" to JsonPrimitive(url),
|
|
||||||
"body" to JsonPrimitive(""),
|
|
||||||
"headers" to JsonObject(emptyMap()),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseHeaders(headersJson: String): Map<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") ?: "Unknown",
|
|
||||||
name = item.stringOrNull("name"),
|
|
||||||
url = url,
|
|
||||||
quality = item.stringOrNull("quality"),
|
|
||||||
size = item.stringOrNull("size"),
|
|
||||||
language = item.stringOrNull("language"),
|
|
||||||
provider = item.stringOrNull("provider"),
|
|
||||||
type = item.stringOrNull("type"),
|
|
||||||
seeders = item["seeders"]?.jsonPrimitive?.intOrNull,
|
|
||||||
peers = item["peers"]?.jsonPrimitive?.intOrNull,
|
|
||||||
infoHash = item.stringOrNull("infoHash"),
|
|
||||||
headers = headers,
|
|
||||||
)
|
|
||||||
}.filter { it.url.isNotBlank() }
|
|
||||||
}.getOrElse { error ->
|
|
||||||
log.e(error) { "Failed to parse plugin result json" }
|
|
||||||
emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun JsonObject.stringOrNull(key: String): String? =
|
|
||||||
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() && !it.contains("[object") }
|
|
||||||
|
|
||||||
private fun toJsonElement(value: Any?): JsonElement = when (value) {
|
|
||||||
null -> JsonNull
|
|
||||||
is JsonElement -> value
|
|
||||||
is String -> JsonPrimitive(value)
|
|
||||||
is Boolean -> JsonPrimitive(value)
|
|
||||||
is Int -> JsonPrimitive(value)
|
|
||||||
is Long -> JsonPrimitive(value)
|
|
||||||
is Float -> JsonPrimitive(value)
|
|
||||||
is Double -> JsonPrimitive(value)
|
|
||||||
is Number -> JsonPrimitive(value.toDouble())
|
|
||||||
is Map<*, *> -> JsonObject(
|
|
||||||
value.entries
|
|
||||||
.filter { it.key is String }
|
|
||||||
.associate { (it.key as String) to toJsonElement(it.value) },
|
|
||||||
)
|
|
||||||
is Iterable<*> -> JsonArray(value.map(::toJsonElement))
|
|
||||||
else -> JsonPrimitive(value.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun buildPolyfillCode(scraperId: String, settingsJson: String): String {
|
|
||||||
return """
|
|
||||||
globalThis.SCRAPER_ID = "$scraperId";
|
|
||||||
globalThis.SCRAPER_SETTINGS = $settingsJson;
|
|
||||||
if (typeof globalThis.global === 'undefined') globalThis.global = globalThis;
|
|
||||||
if (typeof globalThis.window === 'undefined') globalThis.window = globalThis;
|
|
||||||
if (typeof globalThis.self === 'undefined') globalThis.self = globalThis;
|
|
||||||
|
|
||||||
var fetch = async function(url, options) {
|
|
||||||
options = options || {};
|
|
||||||
var method = (options.method || 'GET').toUpperCase();
|
|
||||||
var headers = options.headers || {};
|
|
||||||
var body = options.body || '';
|
|
||||||
var result = __native_fetch(url, method, JSON.stringify(headers), body);
|
|
||||||
var parsed = JSON.parse(result);
|
|
||||||
return {
|
|
||||||
ok: parsed.ok,
|
|
||||||
status: parsed.status,
|
|
||||||
statusText: parsed.statusText,
|
|
||||||
url: parsed.url,
|
|
||||||
headers: {
|
|
||||||
get: function(name) {
|
|
||||||
return parsed.headers[name.toLowerCase()] || null;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
text: function() { return Promise.resolve(parsed.body); },
|
|
||||||
json: function() {
|
|
||||||
try {
|
|
||||||
if (parsed.body === null || parsed.body === undefined || parsed.body === '') {
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
return Promise.resolve(JSON.parse(parsed.body));
|
|
||||||
} catch (e) {
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof AbortSignal === 'undefined') {
|
|
||||||
var AbortSignal = function() { this.aborted = false; this.reason = undefined; this._listeners = []; };
|
|
||||||
AbortSignal.prototype.addEventListener = function(type, listener) {
|
|
||||||
if (type !== 'abort' || typeof listener !== 'function') return;
|
|
||||||
this._listeners.push(listener);
|
|
||||||
};
|
|
||||||
AbortSignal.prototype.removeEventListener = function(type, listener) {
|
|
||||||
if (type !== 'abort') return;
|
|
||||||
this._listeners = this._listeners.filter(function(l) { return l !== listener; });
|
|
||||||
};
|
|
||||||
AbortSignal.prototype.dispatchEvent = function(event) {
|
|
||||||
if (!event || event.type !== 'abort') return true;
|
|
||||||
for (var i = 0; i < this._listeners.length; i++) {
|
|
||||||
try { this._listeners[i].call(this, event); } catch (e) {}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
globalThis.AbortSignal = AbortSignal;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof AbortController === 'undefined') {
|
|
||||||
var AbortController = function() { this.signal = new AbortSignal(); };
|
|
||||||
AbortController.prototype.abort = function(reason) {
|
|
||||||
if (this.signal.aborted) return;
|
|
||||||
this.signal.aborted = true;
|
|
||||||
this.signal.reason = reason;
|
|
||||||
this.signal.dispatchEvent({ type: 'abort' });
|
|
||||||
};
|
|
||||||
globalThis.AbortController = AbortController;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof atob === 'undefined') {
|
|
||||||
globalThis.atob = function(input) {
|
|
||||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
|
||||||
var str = String(input).replace(/=+$/, '');
|
|
||||||
if (str.length % 4 === 1) throw new Error('InvalidCharacterError');
|
|
||||||
var output = '';
|
|
||||||
var bc = 0, bs, buffer, idx = 0;
|
|
||||||
while ((buffer = str.charAt(idx++))) {
|
|
||||||
buffer = chars.indexOf(buffer);
|
|
||||||
if (buffer === -1) continue;
|
|
||||||
bs = bc % 4 ? bs * 64 + buffer : buffer;
|
|
||||||
if (bc++ % 4) output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6)));
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof btoa === 'undefined') {
|
|
||||||
globalThis.btoa = function(input) {
|
|
||||||
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
|
||||||
var str = String(input);
|
|
||||||
var output = '';
|
|
||||||
for (var block, charCode, idx = 0, map = chars;
|
|
||||||
str.charAt(idx | 0) || (map = '=', idx % 1);
|
|
||||||
output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))) {
|
|
||||||
charCode = str.charCodeAt(idx += 3 / 4);
|
|
||||||
if (charCode > 0xFF) throw new Error('InvalidCharacterError');
|
|
||||||
block = (block << 8) | charCode;
|
|
||||||
}
|
|
||||||
return output;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var URL = function(urlString, base) {
|
|
||||||
var fullUrl = urlString;
|
|
||||||
if (base && !/^https?:\/\//i.test(urlString)) {
|
|
||||||
var b = typeof base === 'string' ? base : base.href;
|
|
||||||
if (urlString.charAt(0) === '/') {
|
|
||||||
var m = b.match(/^(https?:\/\/[^\/]+)/);
|
|
||||||
fullUrl = m ? m[1] + urlString : urlString;
|
|
||||||
} else {
|
|
||||||
fullUrl = b.replace(/\/[^\/]*$/, '/') + urlString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var parsed = __parse_url(fullUrl);
|
|
||||||
var data = JSON.parse(parsed);
|
|
||||||
this.href = fullUrl;
|
|
||||||
this.protocol = data.protocol;
|
|
||||||
this.host = data.host;
|
|
||||||
this.hostname = data.hostname;
|
|
||||||
this.port = data.port;
|
|
||||||
this.pathname = data.pathname;
|
|
||||||
this.search = data.search;
|
|
||||||
this.hash = data.hash;
|
|
||||||
this.origin = data.protocol + '//' + data.host;
|
|
||||||
this.searchParams = new URLSearchParams(data.search || '');
|
|
||||||
};
|
|
||||||
URL.prototype.toString = function() { return this.href; };
|
|
||||||
|
|
||||||
var URLSearchParams = function(init) {
|
|
||||||
this._params = {};
|
|
||||||
var self = this;
|
|
||||||
if (init && typeof init === 'object' && !Array.isArray(init)) {
|
|
||||||
Object.keys(init).forEach(function(key) { self._params[key] = String(init[key]); });
|
|
||||||
} else if (typeof init === 'string') {
|
|
||||||
init.replace(/^\?/, '').split('&').forEach(function(pair) {
|
|
||||||
var parts = pair.split('=');
|
|
||||||
if (parts[0]) self._params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1] || '');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
URLSearchParams.prototype.toString = function() {
|
|
||||||
var self = this;
|
|
||||||
return Object.keys(this._params).map(function(key) {
|
|
||||||
return encodeURIComponent(key) + '=' + encodeURIComponent(self._params[key]);
|
|
||||||
}).join('&');
|
|
||||||
};
|
|
||||||
URLSearchParams.prototype.get = function(key) { return this._params.hasOwnProperty(key) ? this._params[key] : null; };
|
|
||||||
URLSearchParams.prototype.set = function(key, value) { this._params[key] = String(value); };
|
|
||||||
URLSearchParams.prototype.append = function(key, value) { this._params[key] = String(value); };
|
|
||||||
URLSearchParams.prototype.has = function(key) { return this._params.hasOwnProperty(key); };
|
|
||||||
URLSearchParams.prototype.delete = function(key) { delete this._params[key]; };
|
|
||||||
URLSearchParams.prototype.keys = function() { return Object.keys(this._params); };
|
|
||||||
URLSearchParams.prototype.values = function() {
|
|
||||||
var self = this;
|
|
||||||
return Object.keys(this._params).map(function(k) { return self._params[k]; });
|
|
||||||
};
|
|
||||||
URLSearchParams.prototype.entries = function() {
|
|
||||||
var self = this;
|
|
||||||
return Object.keys(this._params).map(function(k) { return [k, self._params[k]]; });
|
|
||||||
};
|
|
||||||
URLSearchParams.prototype.forEach = function(callback) {
|
|
||||||
var self = this;
|
|
||||||
Object.keys(this._params).forEach(function(key) { callback(self._params[key], key, self); });
|
|
||||||
};
|
|
||||||
URLSearchParams.prototype.getAll = function(key) {
|
|
||||||
return this._params.hasOwnProperty(key) ? [this._params[key]] : [];
|
|
||||||
};
|
|
||||||
URLSearchParams.prototype.sort = function() {
|
|
||||||
var sorted = {};
|
|
||||||
var self = this;
|
|
||||||
Object.keys(this._params).sort().forEach(function(k) { sorted[k] = self._params[k]; });
|
|
||||||
this._params = sorted;
|
|
||||||
};
|
|
||||||
|
|
||||||
function __hexToWords(hex) {
|
|
||||||
var words = [];
|
|
||||||
for (var i = 0; i < hex.length; i += 8) {
|
|
||||||
var chunk = hex.substring(i, i + 8);
|
|
||||||
while (chunk.length < 8) chunk += '0';
|
|
||||||
words.push(parseInt(chunk, 16) | 0);
|
|
||||||
}
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
|
|
||||||
function __wordsToHex(words, sigBytes) {
|
|
||||||
var hex = '';
|
|
||||||
for (var i = 0; i < sigBytes; i++) {
|
|
||||||
var word = words[i >>> 2] || 0;
|
|
||||||
var byte = (word >>> (24 - (i % 4) * 8)) & 0xff;
|
|
||||||
var part = byte.toString(16);
|
|
||||||
if (part.length < 2) part = '0' + part;
|
|
||||||
hex += part;
|
|
||||||
}
|
|
||||||
return hex;
|
|
||||||
}
|
|
||||||
|
|
||||||
function __wordArrayToHex(value) {
|
|
||||||
if (!value) return '';
|
|
||||||
if (typeof value.__hex === 'string') return value.__hex.toLowerCase();
|
|
||||||
if (Array.isArray(value.words) && typeof value.sigBytes === 'number') {
|
|
||||||
return __wordsToHex(value.words, value.sigBytes);
|
|
||||||
}
|
|
||||||
return __crypto_utf8_to_hex(String(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function __buildWordArray(hex, utf8Override) {
|
|
||||||
var normalizedHex = (hex || '').toLowerCase();
|
|
||||||
if (normalizedHex.length % 2 !== 0) normalizedHex = '0' + normalizedHex;
|
|
||||||
var wordArray = {
|
|
||||||
__hex: normalizedHex,
|
|
||||||
__utf8: utf8Override !== undefined ? utf8Override : __crypto_hex_to_utf8(normalizedHex),
|
|
||||||
sigBytes: normalizedHex.length / 2,
|
|
||||||
words: __hexToWords(normalizedHex),
|
|
||||||
toString: function(encoder) {
|
|
||||||
if (!encoder || encoder === CryptoJS.enc.Hex) return this.__hex;
|
|
||||||
if (encoder === CryptoJS.enc.Utf8) return this.__utf8;
|
|
||||||
if (encoder === CryptoJS.enc.Base64) return __crypto_base64_encode(this.__utf8);
|
|
||||||
return this.__hex;
|
|
||||||
},
|
|
||||||
clamp: function() {
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
concat: function(other) {
|
|
||||||
var otherHex = __wordArrayToHex(other);
|
|
||||||
this.__hex += otherHex;
|
|
||||||
this.__utf8 = __crypto_hex_to_utf8(this.__hex);
|
|
||||||
this.sigBytes = this.__hex.length / 2;
|
|
||||||
this.words = __hexToWords(this.__hex);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return wordArray;
|
|
||||||
}
|
|
||||||
|
|
||||||
function __wordArrayFromHex(hex) {
|
|
||||||
return __buildWordArray(hex, undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
function __wordArrayFromUtf8(text) {
|
|
||||||
var utf8 = text == null ? '' : String(text);
|
|
||||||
return __buildWordArray(__crypto_utf8_to_hex(utf8), utf8);
|
|
||||||
}
|
|
||||||
|
|
||||||
function __wordArrayFromBase64(base64) {
|
|
||||||
return __wordArrayFromUtf8(__crypto_base64_decode(base64 || ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
function __normalizeWordArrayInput(value) {
|
|
||||||
if (value && typeof value === 'object' && typeof value.__utf8 === 'string') {
|
|
||||||
return value.__utf8;
|
|
||||||
}
|
|
||||||
if (value && typeof value === 'object' && typeof value.__hex === 'string') {
|
|
||||||
return __crypto_hex_to_utf8(value.__hex);
|
|
||||||
}
|
|
||||||
if (value && typeof value === 'object' && Array.isArray(value.words) && typeof value.sigBytes === 'number') {
|
|
||||||
return __crypto_hex_to_utf8(__wordsToHex(value.words, value.sigBytes));
|
|
||||||
}
|
|
||||||
if (value == null) return '';
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function __cryptoHashWordArray(algorithm, message) {
|
|
||||||
var utf8 = __normalizeWordArrayInput(message);
|
|
||||||
var hex = __crypto_digest_hex(algorithm, utf8);
|
|
||||||
return __wordArrayFromHex(hex);
|
|
||||||
}
|
|
||||||
|
|
||||||
function __cryptoHmacWordArray(algorithm, message, key) {
|
|
||||||
var utf8Message = __normalizeWordArrayInput(message);
|
|
||||||
var utf8Key = __normalizeWordArrayInput(key);
|
|
||||||
var hex = __crypto_hmac_hex(algorithm, utf8Key, utf8Message);
|
|
||||||
return __wordArrayFromHex(hex);
|
|
||||||
}
|
|
||||||
|
|
||||||
var CryptoJS = {
|
|
||||||
enc: {
|
|
||||||
Hex: {
|
|
||||||
stringify: function(wordArray) {
|
|
||||||
return __wordArrayToHex(wordArray);
|
|
||||||
},
|
|
||||||
parse: function(hexStr) {
|
|
||||||
return __wordArrayFromHex(hexStr || '');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Utf8: {
|
|
||||||
stringify: function(wordArray) {
|
|
||||||
if (wordArray && typeof wordArray.__utf8 === 'string') return wordArray.__utf8;
|
|
||||||
if (wordArray && typeof wordArray.__hex === 'string') return __crypto_hex_to_utf8(wordArray.__hex);
|
|
||||||
return __normalizeWordArrayInput(wordArray);
|
|
||||||
},
|
|
||||||
parse: function(text) {
|
|
||||||
return __wordArrayFromUtf8(text);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Base64: {
|
|
||||||
stringify: function(wordArray) {
|
|
||||||
if (wordArray && typeof wordArray.__utf8 === 'string') {
|
|
||||||
return __crypto_base64_encode(wordArray.__utf8);
|
|
||||||
}
|
|
||||||
return __crypto_base64_encode(__normalizeWordArrayInput(wordArray));
|
|
||||||
},
|
|
||||||
parse: function(base64) {
|
|
||||||
return __wordArrayFromBase64(base64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
MD5: function(message) { return __cryptoHashWordArray('MD5', message); },
|
|
||||||
SHA1: function(message) { return __cryptoHashWordArray('SHA1', message); },
|
|
||||||
SHA256: function(message) { return __cryptoHashWordArray('SHA256', message); },
|
|
||||||
SHA512: function(message) { return __cryptoHashWordArray('SHA512', message); },
|
|
||||||
HmacMD5: function(message, key) { return __cryptoHmacWordArray('MD5', message, key); },
|
|
||||||
HmacSHA1: function(message, key) { return __cryptoHmacWordArray('SHA1', message, key); },
|
|
||||||
HmacSHA256: function(message, key) { return __cryptoHmacWordArray('SHA256', message, key); },
|
|
||||||
HmacSHA512: function(message, key) { return __cryptoHmacWordArray('SHA512', message, key); }
|
|
||||||
};
|
|
||||||
globalThis.CryptoJS = CryptoJS;
|
|
||||||
|
|
||||||
var cheerio = {
|
|
||||||
load: function(html) {
|
|
||||||
var docId = __cheerio_load(html);
|
|
||||||
var $ = function(selector, context) {
|
|
||||||
if (selector && selector._elementIds) return selector;
|
|
||||||
if (context && context._elementIds && context._elementIds.length > 0) {
|
|
||||||
var allIds = [];
|
|
||||||
for (var i = 0; i < context._elementIds.length; i++) {
|
|
||||||
var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector);
|
|
||||||
var childIds = JSON.parse(childIdsJson);
|
|
||||||
allIds = allIds.concat(childIds);
|
|
||||||
}
|
|
||||||
return createCheerioWrapperFromIds(docId, allIds);
|
|
||||||
}
|
|
||||||
return createCheerioWrapper(docId, selector);
|
|
||||||
};
|
|
||||||
$.html = function(el) {
|
|
||||||
if (el && el._elementIds && el._elementIds.length > 0) {
|
|
||||||
return __cheerio_html(docId, el._elementIds[0]);
|
|
||||||
}
|
|
||||||
return __cheerio_html(docId, '');
|
|
||||||
};
|
|
||||||
return $;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function createCheerioWrapper(docId, selector) {
|
|
||||||
var elementIds;
|
|
||||||
if (typeof selector === 'string') {
|
|
||||||
var idsJson = __cheerio_select(docId, selector);
|
|
||||||
elementIds = JSON.parse(idsJson);
|
|
||||||
} else {
|
|
||||||
elementIds = [];
|
|
||||||
}
|
|
||||||
return createCheerioWrapperFromIds(docId, elementIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createCheerioWrapperFromIds(docId, ids) {
|
|
||||||
var wrapper = {
|
|
||||||
_docId: docId,
|
|
||||||
_elementIds: ids,
|
|
||||||
length: ids.length,
|
|
||||||
each: function(callback) {
|
|
||||||
for (var i = 0; i < ids.length; i++) {
|
|
||||||
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
|
|
||||||
callback.call(elWrapper, i, elWrapper);
|
|
||||||
}
|
|
||||||
return wrapper;
|
|
||||||
},
|
|
||||||
find: function(sel) {
|
|
||||||
var allIds = [];
|
|
||||||
for (var i = 0; i < ids.length; i++) {
|
|
||||||
var childIdsJson = __cheerio_find(docId, ids[i], sel);
|
|
||||||
var childIds = JSON.parse(childIdsJson);
|
|
||||||
allIds = allIds.concat(childIds);
|
|
||||||
}
|
|
||||||
return createCheerioWrapperFromIds(docId, allIds);
|
|
||||||
},
|
|
||||||
text: function() {
|
|
||||||
if (ids.length === 0) return '';
|
|
||||||
return __cheerio_text(docId, ids.join(','));
|
|
||||||
},
|
|
||||||
html: function() {
|
|
||||||
if (ids.length === 0) return '';
|
|
||||||
return __cheerio_inner_html(docId, ids[0]);
|
|
||||||
},
|
|
||||||
attr: function(name) {
|
|
||||||
if (ids.length === 0) return undefined;
|
|
||||||
var val = __cheerio_attr(docId, ids[0], name);
|
|
||||||
return val === '__UNDEFINED__' ? undefined : val;
|
|
||||||
},
|
|
||||||
first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); },
|
|
||||||
last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); },
|
|
||||||
next: function() {
|
|
||||||
var nextIds = [];
|
|
||||||
for (var i = 0; i < ids.length; i++) {
|
|
||||||
var nextId = __cheerio_next(docId, ids[i]);
|
|
||||||
if (nextId && nextId !== '__NONE__') nextIds.push(nextId);
|
|
||||||
}
|
|
||||||
return createCheerioWrapperFromIds(docId, nextIds);
|
|
||||||
},
|
|
||||||
prev: function() {
|
|
||||||
var prevIds = [];
|
|
||||||
for (var i = 0; i < ids.length; i++) {
|
|
||||||
var prevId = __cheerio_prev(docId, ids[i]);
|
|
||||||
if (prevId && prevId !== '__NONE__') prevIds.push(prevId);
|
|
||||||
}
|
|
||||||
return createCheerioWrapperFromIds(docId, prevIds);
|
|
||||||
},
|
|
||||||
eq: function(index) {
|
|
||||||
if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]);
|
|
||||||
return createCheerioWrapperFromIds(docId, []);
|
|
||||||
},
|
|
||||||
get: function(index) {
|
|
||||||
if (typeof index === 'number') {
|
|
||||||
if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); });
|
|
||||||
},
|
|
||||||
map: function(callback) {
|
|
||||||
var results = [];
|
|
||||||
for (var i = 0; i < ids.length; i++) {
|
|
||||||
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
|
|
||||||
var result = callback.call(elWrapper, i, elWrapper);
|
|
||||||
if (result !== undefined && result !== null) results.push(result);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
length: results.length,
|
|
||||||
get: function(index) { return typeof index === 'number' ? results[index] : results; },
|
|
||||||
toArray: function() { return results; }
|
|
||||||
};
|
|
||||||
},
|
|
||||||
filter: function(selectorOrCallback) {
|
|
||||||
if (typeof selectorOrCallback === 'function') {
|
|
||||||
var filteredIds = [];
|
|
||||||
for (var i = 0; i < ids.length; i++) {
|
|
||||||
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
|
|
||||||
var result = selectorOrCallback.call(elWrapper, i, elWrapper);
|
|
||||||
if (result) filteredIds.push(ids[i]);
|
|
||||||
}
|
|
||||||
return createCheerioWrapperFromIds(docId, filteredIds);
|
|
||||||
}
|
|
||||||
return wrapper;
|
|
||||||
},
|
|
||||||
children: function(sel) { return this.find(sel || '*'); },
|
|
||||||
parent: function() { return createCheerioWrapperFromIds(docId, []); },
|
|
||||||
toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); }
|
|
||||||
};
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
var require = function(moduleName) {
|
|
||||||
if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') {
|
|
||||||
return cheerio;
|
|
||||||
}
|
|
||||||
if (moduleName === 'crypto-js') {
|
|
||||||
return CryptoJS;
|
|
||||||
}
|
|
||||||
throw new Error("Module '" + moduleName + "' is not available");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!Array.prototype.flat) {
|
|
||||||
Array.prototype.flat = function(depth) {
|
|
||||||
depth = depth === undefined ? 1 : Math.floor(depth);
|
|
||||||
if (depth < 1) return Array.prototype.slice.call(this);
|
|
||||||
return (function flatten(arr, d) {
|
|
||||||
return d > 0
|
|
||||||
? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, [])
|
|
||||||
: arr.slice();
|
|
||||||
})(this, depth);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.prototype.flatMap) {
|
|
||||||
Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Object.entries) {
|
|
||||||
Object.entries = function(obj) {
|
|
||||||
var result = [];
|
|
||||||
for (var key in obj) {
|
|
||||||
if (obj.hasOwnProperty(key)) result.push([key, obj[key]]);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Object.fromEntries) {
|
|
||||||
Object.fromEntries = function(entries) {
|
|
||||||
var result = {};
|
|
||||||
for (var i = 0; i < entries.length; i++) {
|
|
||||||
result[entries[i][0]] = entries[i][1];
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!String.prototype.replaceAll) {
|
|
||||||
String.prototype.replaceAll = function(search, replace) {
|
|
||||||
if (search instanceof RegExp) {
|
|
||||||
if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp');
|
|
||||||
return this.replace(search, replace);
|
|
||||||
}
|
|
||||||
return this.split(search).join(replace);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
""".trimIndent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,434 +0,0 @@
|
||||||
package com.nuvio.app.features.plugins
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Bolt
|
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Extension
|
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.nuvio.app.core.ui.NuvioIconActionButton
|
|
||||||
import com.nuvio.app.core.ui.NuvioInfoBadge
|
|
||||||
import com.nuvio.app.core.ui.NuvioInputField
|
|
||||||
import com.nuvio.app.core.ui.NuvioPrimaryButton
|
|
||||||
import com.nuvio.app.core.ui.NuvioSectionLabel
|
|
||||||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PluginsSettingsPageContent(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
PluginRepository.initialize()
|
|
||||||
}
|
|
||||||
|
|
||||||
val uiState by PluginRepository.uiState.collectAsStateWithLifecycle()
|
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
|
|
||||||
var repositoryUrl by rememberSaveable { mutableStateOf("") }
|
|
||||||
var message by rememberSaveable { mutableStateOf<String?>(null) }
|
|
||||||
var isAdding by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
var testingScraperId by remember { mutableStateOf<String?>(null) }
|
|
||||||
val testResults = remember { mutableStateMapOf<String, List<PluginRuntimeResult>>() }
|
|
||||||
|
|
||||||
val sortedRepos = remember(uiState.repositories) {
|
|
||||||
uiState.repositories.sortedBy { it.name.lowercase() }
|
|
||||||
}
|
|
||||||
val repositoryNameByUrl = remember(sortedRepos) {
|
|
||||||
sortedRepos.associate { it.manifestUrl to it.name }
|
|
||||||
}
|
|
||||||
val sortedScrapers = remember(uiState.scrapers, repositoryNameByUrl) {
|
|
||||||
uiState.scrapers.sortedWith(
|
|
||||||
compareBy<PluginScraper>(
|
|
||||||
{ repositoryNameByUrl[it.repositoryUrl]?.lowercase() ?: it.repositoryUrl.lowercase() },
|
|
||||||
{ it.name.lowercase() },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
NuvioSectionLabel("OVERVIEW")
|
|
||||||
NuvioSurfaceCard {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
NuvioInfoBadge(text = "${sortedRepos.size} repos")
|
|
||||||
NuvioInfoBadge(text = "${sortedScrapers.size} providers")
|
|
||||||
NuvioInfoBadge(
|
|
||||||
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Enable plugin providers globally",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = "Use plugin providers during stream discovery.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Switch(
|
|
||||||
checked = uiState.pluginsEnabled,
|
|
||||||
onCheckedChange = { PluginRepository.setPluginsEnabled(it) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Group plugin providers by repository",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = "In Streams, show one provider per repository instead of one per source.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Switch(
|
|
||||||
checked = uiState.groupStreamsByRepository,
|
|
||||||
onCheckedChange = { PluginRepository.setGroupStreamsByRepository(it) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NuvioSectionLabel("ADD REPOSITORY")
|
|
||||||
NuvioSurfaceCard {
|
|
||||||
NuvioInputField(
|
|
||||||
value = repositoryUrl,
|
|
||||||
onValueChange = {
|
|
||||||
repositoryUrl = it
|
|
||||||
message = null
|
|
||||||
},
|
|
||||||
placeholder = "Plugin manifest URL",
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
NuvioPrimaryButton(
|
|
||||||
text = if (isAdding) "Installing..." else "Install Plugin Repository",
|
|
||||||
enabled = repositoryUrl.isNotBlank() && !isAdding,
|
|
||||||
onClick = {
|
|
||||||
val requested = repositoryUrl.trim()
|
|
||||||
if (requested.isBlank()) {
|
|
||||||
message = "Enter a plugin repository URL."
|
|
||||||
return@NuvioPrimaryButton
|
|
||||||
}
|
|
||||||
isAdding = true
|
|
||||||
message = null
|
|
||||||
coroutineScope.launch {
|
|
||||||
when (val result = PluginRepository.addRepository(requested)) {
|
|
||||||
is AddPluginRepositoryResult.Success -> {
|
|
||||||
repositoryUrl = ""
|
|
||||||
message = "Installed ${result.repository.name}."
|
|
||||||
}
|
|
||||||
is AddPluginRepositoryResult.Error -> {
|
|
||||||
message = result.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isAdding = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
message?.let { text ->
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NuvioSectionLabel("INSTALLED REPOSITORIES")
|
|
||||||
if (sortedRepos.isEmpty()) {
|
|
||||||
NuvioSurfaceCard {
|
|
||||||
Text(
|
|
||||||
text = "No plugin repositories installed yet.",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "Add a repository URL to install provider plugins for stream discovery.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sortedRepos.forEach { repo ->
|
|
||||||
NuvioSurfaceCard {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = repo.name,
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
repo.version?.let { version ->
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
text = "Version $version",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = repo.manifestUrl,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
NuvioIconActionButton(
|
|
||||||
icon = Icons.Rounded.Refresh,
|
|
||||||
contentDescription = "Refresh plugin repository",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
onClick = { PluginRepository.refreshRepository(repo.manifestUrl, pushAfterRefresh = true) },
|
|
||||||
)
|
|
||||||
NuvioIconActionButton(
|
|
||||||
icon = Icons.Rounded.Delete,
|
|
||||||
contentDescription = "Delete plugin repository",
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
onClick = { PluginRepository.removeRepository(repo.manifestUrl) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(14.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
NuvioInfoBadge(text = "${repo.scraperCount} providers")
|
|
||||||
if (repo.isRefreshing) {
|
|
||||||
NuvioInfoBadge(text = "Refreshing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
repo.errorMessage?.let { errorMessage ->
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
Text(
|
|
||||||
text = errorMessage,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NuvioSectionLabel("PROVIDERS")
|
|
||||||
if (sortedScrapers.isEmpty()) {
|
|
||||||
NuvioSurfaceCard {
|
|
||||||
Text(
|
|
||||||
text = "No providers available yet.",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sortedScrapers.forEach { scraper ->
|
|
||||||
val scraperResults = testResults[scraper.id]
|
|
||||||
val isTestingThisScraper = testingScraperId == scraper.id
|
|
||||||
val repositoryName = repositoryNameByUrl[scraper.repositoryUrl]
|
|
||||||
?: scraper.repositoryUrl.fallbackRepositoryLabel()
|
|
||||||
|
|
||||||
NuvioSurfaceCard {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.Extension,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (scraper.enabled) Color(0xFF68B76A) else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(10.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = repositoryName,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(
|
|
||||||
text = scraper.name,
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = scraper.description.ifBlank { "No description" },
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Switch(
|
|
||||||
checked = scraper.enabled,
|
|
||||||
onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) },
|
|
||||||
enabled = scraper.manifestEnabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(10.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
NuvioInfoBadge(text = scraper.supportedTypes.joinToString(" | "))
|
|
||||||
NuvioInfoBadge(text = "v${scraper.version}")
|
|
||||||
if (!scraper.manifestEnabled) {
|
|
||||||
NuvioInfoBadge(text = "Disabled by repo")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
NuvioPrimaryButton(
|
|
||||||
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
|
|
||||||
enabled = !isTestingThisScraper,
|
|
||||||
onClick = {
|
|
||||||
testingScraperId = scraper.id
|
|
||||||
coroutineScope.launch {
|
|
||||||
PluginRepository.testScraper(scraper.id)
|
|
||||||
.onSuccess { results ->
|
|
||||||
testResults[scraper.id] = results
|
|
||||||
}
|
|
||||||
.onFailure { error ->
|
|
||||||
testResults[scraper.id] = listOf(
|
|
||||||
PluginRuntimeResult(
|
|
||||||
title = "Error",
|
|
||||||
name = error.message ?: "Provider test failed",
|
|
||||||
url = "about:error",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
testingScraperId = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!scraperResults.isNullOrEmpty()) {
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
Text(
|
|
||||||
text = "Test results (${scraperResults.size})",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
scraperResults.take(8).forEach { result ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.Top,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Rounded.Bolt,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = result.title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = result.url,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.height(6.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.fallbackRepositoryLabel(): String {
|
|
||||||
val withoutQuery = substringBefore("?")
|
|
||||||
val withoutManifest = withoutQuery.removeSuffix("/manifest.json")
|
|
||||||
val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/')
|
|
||||||
return host.ifBlank {
|
|
||||||
withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package com.nuvio.app.features.settings
|
|
||||||
|
|
||||||
import androidx.compose.foundation.lazy.LazyListScope
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import com.nuvio.app.features.plugins.PluginsSettingsPageContent
|
|
||||||
|
|
||||||
internal actual fun LazyListScope.pluginsSettingsContent() {
|
|
||||||
item {
|
|
||||||
PluginsSettingsPageContent(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue