plugins init

This commit is contained in:
tapframe 2026-04-02 12:11:16 +05:30
parent 8451fff320
commit d7efa1ff17
29 changed files with 2289 additions and 23 deletions

View file

@ -145,6 +145,8 @@ kotlin {
implementation(libs.supabase.postgrest)
implementation(libs.supabase.auth)
implementation(libs.supabase.functions)
implementation(libs.quickjs.kt)
implementation(libs.ksoup)
}
iosMain.dependencies {
implementation(libs.ktor.client.darwin)

View file

@ -13,6 +13,7 @@ import com.nuvio.app.features.library.LibraryStorage
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
import com.nuvio.app.features.mdblist.MdbListSettingsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.plugins.PluginStorage
import com.nuvio.app.features.profiles.ProfileStorage
import com.nuvio.app.features.details.SeasonViewModeStorage
import com.nuvio.app.features.search.SearchHistoryStorage
@ -52,6 +53,7 @@ class MainActivity : ComponentActivity() {
ContinueWatchingPreferencesStorage.initialize(applicationContext)
WatchProgressStorage.initialize(applicationContext)
StreamLinkCacheStorage.initialize(applicationContext)
PluginStorage.initialize(applicationContext)
PlatformLocalAccountDataCleaner.initialize(applicationContext)
forwardTraktAuthCallback(intent)

View file

@ -16,6 +16,7 @@ internal actual object PlatformLocalAccountDataCleaner {
"nuvio_stream_link_cache",
"nuvio_continue_watching_preferences",
"nuvio_watch_progress",
"nuvio_plugins",
)
private var appContext: Context? = null

View file

@ -9,10 +9,13 @@ import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.isSuccess
actual object AddonStorage {
@ -131,3 +134,32 @@ actual suspend fun httpPostJsonWithHeaders(
}
payload
}
actual suspend fun httpRequestRaw(
method: String,
url: String,
headers: Map<String, String>,
body: String,
): RawHttpResponse =
addonHttpClient
.request {
url(url)
this.method = HttpMethod.parse(method.uppercase())
headers.forEach { (key, value) ->
header(key, value)
}
if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) {
setBody(body)
}
}
.let { response ->
RawHttpResponse(
status = response.status.value,
statusText = response.status.description,
url = response.call.request.url.toString(),
body = response.bodyAsText(),
headers = response.headers.entries().associate { (name, values) ->
name.lowercase() to values.joinToString(",")
},
)
}

View file

@ -0,0 +1,29 @@
package com.nuvio.app.features.plugins
import android.content.Context
import android.content.SharedPreferences
internal actual object PluginStorage {
private const val preferencesName = "nuvio_plugins"
private const val pluginsStateKey = "plugins_state"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun loadState(profileId: Int): String? =
preferences?.getString("${pluginsStateKey}_$profileId", null)
actual fun saveState(profileId: Int, payload: String) {
preferences
?.edit()
?.putString("${pluginsStateKey}_$profileId", payload)
?.apply()
}
}
internal actual fun currentPluginPlatform(): String = "android"
internal actual fun currentEpochMillis(): Long = System.currentTimeMillis()

View file

@ -101,6 +101,7 @@ import com.nuvio.app.features.settings.SettingsScreen
import com.nuvio.app.features.settings.HomescreenSettingsScreen
import com.nuvio.app.features.settings.ContinueWatchingSettingsScreen
import com.nuvio.app.features.settings.AddonsSettingsScreen
import com.nuvio.app.features.settings.PluginsSettingsScreen
import com.nuvio.app.features.settings.AccountSettingsScreen
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.streams.StreamContext
@ -136,6 +137,9 @@ object ContinueWatchingSettingsRoute
@Serializable
object AddonsSettingsRoute
@Serializable
object PluginsSettingsRoute
@Serializable
object AccountSettingsRoute
@ -506,6 +510,7 @@ private fun MainAppContent(
onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) },
onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) },
onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) },
onPluginsSettingsClick = { navController.navigate(PluginsSettingsRoute) },
onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) },
onInitialHomeContentRendered = { initialHomeReady = true },
)
@ -816,6 +821,11 @@ private fun MainAppContent(
onBack = { navController.popBackStack() },
)
}
composable<PluginsSettingsRoute> {
PluginsSettingsScreen(
onBack = { navController.popBackStack() },
)
}
composable<AccountSettingsRoute> {
AccountSettingsScreen(
onBack = { navController.popBackStack() },
@ -883,6 +893,7 @@ private fun AppTabHost(
onHomescreenSettingsClick: () -> Unit = {},
onContinueWatchingSettingsClick: () -> Unit = {},
onAddonsSettingsClick: () -> Unit = {},
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
onInitialHomeContentRendered: () -> Unit = {},
) {
@ -927,6 +938,7 @@ private fun AppTabHost(
onHomescreenClick = onHomescreenSettingsClick,
onContinueWatchingClick = onContinueWatchingSettingsClick,
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
)
}

View file

@ -8,6 +8,7 @@ import com.nuvio.app.features.home.HomeRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.player.PlayerLaunchStore
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.player.SubtitleRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.search.SearchRepository
@ -25,6 +26,7 @@ internal object LocalAccountDataCleaner {
ProfileRepository.clearInMemory()
AddonRepository.clearLocalState()
PluginRepository.clearLocalState()
HomeRepository.clear()
HomeCatalogSettingsRepository.clearLocalState()
LibraryRepository.clearLocalState()

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.watched.WatchedRepository
@ -30,6 +31,11 @@ object SyncManager {
.onSuccess { log.i { "pullAllForProfile — addons pull completed" } }
.onFailure { log.e(it) { "Addon pull failed" } }
log.i { "pullAllForProfile — pulling plugins (await)..." }
runCatching { PluginRepository.pullFromServer(profileId) }
.onSuccess { log.i { "pullAllForProfile — plugins pull completed" } }
.onFailure { log.e(it) { "Plugin pull failed" } }
log.i { "pullAllForProfile — launching remaining pulls in parallel" }
launch {
runCatching { LibraryRepository.pullFromServer(profileId) }

View file

@ -5,6 +5,14 @@ internal expect object AddonStorage {
fun saveInstalledAddonUrls(profileId: Int, urls: List<String>)
}
data class RawHttpResponse(
val status: Int,
val statusText: String,
val url: String,
val body: String,
val headers: Map<String, String>,
)
expect suspend fun httpGetText(url: String): String
expect suspend fun httpPostJson(url: String, body: String): String
@ -19,3 +27,10 @@ expect suspend fun httpPostJsonWithHeaders(
body: String,
headers: Map<String, String>,
): String
expect suspend fun httpRequestRaw(
method: String,
url: String,
headers: Map<String, String>,
body: String,
): RawHttpResponse

View file

@ -520,7 +520,12 @@ fun PlayerScreen(
fun openSourcesPanel() {
val type = contentType ?: parentMetaType
val vid = activeVideoId ?: return
PlayerStreamsRepository.loadSources(type, vid)
PlayerStreamsRepository.loadSources(
type = type,
videoId = vid,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
)
showSourcesPanel = true
showEpisodesPanel = false
controlsVisible = false
@ -961,7 +966,13 @@ fun PlayerScreen(
onReload = {
val type = contentType ?: parentMetaType
val vid = activeVideoId ?: return@PlayerSourcesPanel
PlayerStreamsRepository.loadSources(type, vid, forceRefresh = true)
PlayerStreamsRepository.loadSources(
type = type,
videoId = vid,
season = activeSeasonNumber,
episode = activeEpisodeNumber,
forceRefresh = true,
)
},
onDismiss = {
showSourcesPanel = false
@ -982,7 +993,12 @@ fun PlayerScreen(
onSeasonSelected = { /* season tab change handled internally */ },
onEpisodeSelected = { episode ->
val type = contentType ?: parentMetaType
PlayerStreamsRepository.loadEpisodeStreams(type, episode.id)
PlayerStreamsRepository.loadEpisodeStreams(
type = type,
videoId = episode.id,
season = episode.season,
episode = episode.episode,
)
episodeStreamsPanelState = EpisodeStreamsPanelState(
showStreams = true,
selectedEpisode = episode,
@ -999,7 +1015,13 @@ fun PlayerScreen(
onReloadEpisodeStreams = {
val episode = episodeStreamsPanelState.selectedEpisode ?: return@PlayerEpisodesPanel
val type = contentType ?: parentMetaType
PlayerStreamsRepository.loadEpisodeStreams(type, episode.id, forceRefresh = true)
PlayerStreamsRepository.loadEpisodeStreams(
type = type,
videoId = episode.id,
season = episode.season,
episode = episode.episode,
forceRefresh = true,
)
},
onDismiss = {
showEpisodesPanel = false

View file

@ -4,7 +4,11 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper
import com.nuvio.app.features.streams.AddonStreamGroup
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamParser
import com.nuvio.app.features.streams.StreamsUiState
import kotlinx.coroutines.CoroutineScope
@ -38,10 +42,18 @@ object PlayerStreamsRepository {
private var episodeStreamsJob: Job? = null
private var episodeStreamsRequestKey: String? = null
fun loadSources(type: String, videoId: String, forceRefresh: Boolean = false) {
fun loadSources(
type: String,
videoId: String,
season: Int? = null,
episode: Int? = null,
forceRefresh: Boolean = false,
) {
fetchStreams(
type = type,
videoId = videoId,
season = season,
episode = episode,
forceRefresh = forceRefresh,
stateFlow = _sourceState,
requestKeyHolder = { sourceRequestKey },
@ -51,10 +63,18 @@ object PlayerStreamsRepository {
)
}
fun loadEpisodeStreams(type: String, videoId: String, forceRefresh: Boolean = false) {
fun loadEpisodeStreams(
type: String,
videoId: String,
season: Int? = null,
episode: Int? = null,
forceRefresh: Boolean = false,
) {
fetchStreams(
type = type,
videoId = videoId,
season = season,
episode = episode,
forceRefresh = forceRefresh,
stateFlow = _episodeStreamsState,
requestKeyHolder = { episodeStreamsRequestKey },
@ -88,6 +108,8 @@ object PlayerStreamsRepository {
private fun fetchStreams(
type: String,
videoId: String,
season: Int?,
episode: Int?,
forceRefresh: Boolean,
stateFlow: MutableStateFlow<StreamsUiState>,
requestKeyHolder: () -> String?,
@ -95,7 +117,7 @@ object PlayerStreamsRepository {
jobHolder: () -> Job?,
setJob: (Job) -> Unit,
) {
val requestKey = "$type::$videoId"
val requestKey = "$type::$videoId::$season::$episode"
val current = stateFlow.value
if (
!forceRefresh &&
@ -127,7 +149,10 @@ object PlayerStreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
if (installedAddons.isEmpty()) {
PluginRepository.initialize()
val pluginScrapers = PluginRepository.getEnabledScrapersForType(type)
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled,
@ -146,7 +171,7 @@ object PlayerStreamsRepository {
}
}
if (streamAddons.isEmpty()) {
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
stateFlow.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons,
@ -161,15 +186,22 @@ object PlayerStreamsRepository {
streams = emptyList(),
isLoading = true,
)
} + pluginScrapers.map { scraper ->
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = emptyList(),
isLoading = true,
)
}
stateFlow.value = StreamsUiState(
groups = initialGroups,
activeAddonIds = streamAddons.map { it.id }.toSet(),
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
isAnyLoading = true,
)
val job = scope.launch {
val jobs = streamAddons.map { manifest ->
val addonJobs = streamAddons.map { manifest ->
async {
val encodedId = videoId.replace("%", "%25").replace(" ", "%20")
val baseUrl = manifest.transportUrl
@ -191,6 +223,39 @@ object PlayerStreamsRepository {
)
}
}
val pluginJobs = pluginScrapers.map { scraper ->
async {
PluginRepository.executeScraper(
scraper = scraper,
tmdbId = videoId.toPluginTmdbId(),
mediaType = type,
season = season,
episode = episode,
).fold(
onSuccess = { results ->
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = results.map { it.toStreamItem(scraper) },
isLoading = false,
)
},
onFailure = { err ->
log.w(err) { "Plugin scraper failed: ${scraper.name}" }
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = emptyList(),
isLoading = false,
error = err.message,
)
},
)
}
}
val jobs = addonJobs + pluginJobs
jobs.forEach { deferred ->
val result = deferred.await()
stateFlow.update { current ->
@ -213,3 +278,27 @@ object PlayerStreamsRepository {
setJob(job)
}
}
private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem {
val subtitleParts = listOfNotNull(
quality?.takeIf { it.isNotBlank() },
size?.takeIf { it.isNotBlank() },
language?.takeIf { it.isNotBlank() },
)
return StreamItem(
name = name ?: title,
description = subtitleParts.joinToString("").ifBlank { null },
url = url,
infoHash = infoHash,
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
)
}
private fun String.toPluginTmdbId(): String {
return when {
startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this }
startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this }
else -> this
}
}

View file

@ -0,0 +1,17 @@
package com.nuvio.app.features.plugins
import kotlinx.serialization.json.Json
internal object PluginManifestParser {
private val json = Json {
ignoreUnknownKeys = true
}
fun parse(payload: String): PluginManifest {
val manifest = json.decodeFromString<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 scrapers." }
return manifest
}
}

View file

@ -0,0 +1,130 @@
package com.nuvio.app.features.plugins
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PluginManifest(
val name: String,
val version: String,
val description: String? = null,
val author: String? = null,
val scrapers: List<PluginManifestScraper> = emptyList(),
)
@Serializable
data class PluginManifestScraper(
val id: String,
val name: String,
val description: String? = null,
val version: String,
val filename: String,
@SerialName("supportedTypes") val supportedTypes: List<String> = listOf("movie", "tv"),
val enabled: Boolean = true,
val logo: String? = null,
@SerialName("contentLanguage") val contentLanguage: List<String>? = null,
@SerialName("supportedPlatforms") val supportedPlatforms: List<String>? = null,
@SerialName("disabledPlatforms") val disabledPlatforms: List<String>? = null,
val formats: List<String>? = null,
@SerialName("supportedFormats") val supportedFormats: List<String>? = null,
@SerialName("supportsExternalPlayer") val supportsExternalPlayer: Boolean? = null,
val limited: Boolean? = null,
)
data class PluginRepositoryItem(
val manifestUrl: String,
val name: String,
val description: String? = null,
val version: String? = null,
val scraperCount: Int = 0,
val lastUpdated: Long = 0L,
val isRefreshing: Boolean = false,
val errorMessage: String? = null,
)
data class PluginScraper(
val id: String,
val repositoryUrl: String,
val name: String,
val description: String,
val version: String,
val filename: String,
val supportedTypes: List<String>,
val enabled: Boolean,
val manifestEnabled: Boolean,
val logo: String? = null,
val contentLanguage: List<String> = emptyList(),
val formats: List<String>? = null,
val code: String,
) {
fun supportsType(type: String): Boolean {
val normalizedType = normalizePluginType(type)
return supportedTypes.map { normalizePluginType(it) }.contains(normalizedType)
}
}
data class PluginRuntimeResult(
val title: String,
val name: String? = null,
val url: String,
val quality: String? = null,
val size: String? = null,
val language: String? = null,
val provider: String? = null,
val type: String? = null,
val seeders: Int? = null,
val peers: Int? = null,
val infoHash: String? = null,
val headers: Map<String, String>? = null,
)
data class PluginsUiState(
val pluginsEnabled: Boolean = true,
val repositories: List<PluginRepositoryItem> = emptyList(),
val scrapers: List<PluginScraper> = emptyList(),
)
sealed interface AddPluginRepositoryResult {
data class Success(val repository: PluginRepositoryItem) : AddPluginRepositoryResult
data class Error(val message: String) : AddPluginRepositoryResult
}
@Serializable
internal data class StoredPluginsState(
val pluginsEnabled: Boolean = true,
val repositories: List<StoredPluginRepository> = emptyList(),
val scrapers: List<StoredPluginScraper> = emptyList(),
)
@Serializable
internal data class StoredPluginRepository(
val manifestUrl: String,
val name: String,
val description: String? = null,
val version: String? = null,
val scraperCount: Int = 0,
val lastUpdated: Long = 0L,
)
@Serializable
internal data class StoredPluginScraper(
val id: String,
val repositoryUrl: String,
val name: String,
val description: String,
val version: String,
val filename: String,
val supportedTypes: List<String>,
val enabled: Boolean,
val manifestEnabled: Boolean,
val logo: String? = null,
val contentLanguage: List<String> = emptyList(),
val formats: List<String>? = null,
val code: String,
)
internal fun normalizePluginType(value: String): String =
when (value.lowercase()) {
"series", "show", "other" -> "tv"
else -> value.lowercase()
}

View file

@ -0,0 +1,10 @@
package com.nuvio.app.features.plugins
internal expect object PluginStorage {
fun loadState(profileId: Int): String?
fun saveState(profileId: Int, payload: String)
}
internal expect fun currentPluginPlatform(): String
internal expect fun currentEpochMillis(): Long

View file

@ -0,0 +1,531 @@
package com.nuvio.app.features.plugins
import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.profiles.ProfileRepository
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Order
import io.github.jan.supabase.postgrest.rpc
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.put
@Serializable
private data class PluginRow(
val url: String,
val name: String? = null,
val enabled: Boolean = true,
@SerialName("sort_order") val sortOrder: Int = 0,
)
@Serializable
private data class PluginPushItem(
val url: String,
val name: String = "",
val enabled: Boolean = true,
@SerialName("sort_order") val sortOrder: Int = 0,
)
object PluginRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("PluginRepository")
private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
private val _uiState = MutableStateFlow(PluginsUiState())
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
private var initialized = false
private var pulledFromServer = false
private var currentProfileId = 1
private val activeRefreshJobs = mutableMapOf<String, Job>()
fun initialize() {
if (initialized) return
currentProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId)
val stored = loadStoredState(currentProfileId)
_uiState.value = PluginsUiState(
pluginsEnabled = stored?.pluginsEnabled ?: true,
repositories = stored?.repositories
?.map {
PluginRepositoryItem(
manifestUrl = it.manifestUrl,
name = it.name,
description = it.description,
version = it.version,
scraperCount = it.scraperCount,
lastUpdated = it.lastUpdated,
isRefreshing = false,
errorMessage = null,
)
}
?: emptyList(),
scrapers = stored?.scrapers
?.map {
PluginScraper(
id = it.id,
repositoryUrl = it.repositoryUrl,
name = it.name,
description = it.description,
version = it.version,
filename = it.filename,
supportedTypes = it.supportedTypes,
enabled = it.enabled,
manifestEnabled = it.manifestEnabled,
logo = it.logo,
contentLanguage = it.contentLanguage,
formats = it.formats,
code = it.code,
)
}
?: emptyList(),
)
initialized = true
_uiState.value.repositories.forEach { repo ->
refreshRepository(repo.manifestUrl)
}
}
fun onProfileChanged(profileId: Int) {
val effectiveProfileId = resolveEffectiveProfileId(profileId)
if (effectiveProfileId == currentProfileId && initialized) return
cancelActiveRefreshes()
currentProfileId = effectiveProfileId
initialized = false
pulledFromServer = false
_uiState.value = PluginsUiState()
}
fun clearLocalState() {
cancelActiveRefreshes()
currentProfileId = 1
initialized = false
pulledFromServer = false
_uiState.value = PluginsUiState()
}
suspend fun pullFromServer(profileId: Int) {
currentProfileId = resolveEffectiveProfileId(profileId)
runCatching {
val rows = SupabaseProvider.client.postgrest
.from("plugins")
.select {
filter { eq("profile_id", currentProfileId) }
order("sort_order", Order.ASCENDING)
}
.decodeList<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,
repositories = nextRepos,
scrapers = nextScrapers,
)
persist()
urls.forEach { url ->
refreshRepository(url, pushAfterRefresh = false)
}
pulledFromServer = true
initialized = true
}.onFailure { error ->
log.e(error) { "pullFromServer failed" }
}
}
suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult {
initialize()
val manifestUrl = try {
normalizeManifestUrl(rawUrl)
} catch (error: IllegalArgumentException) {
return AddPluginRepositoryResult.Error(error.message ?: "Enter a valid plugin URL")
}
if (_uiState.value.repositories.any { it.manifestUrl == manifestUrl }) {
return AddPluginRepositoryResult.Error("That plugin repository is already installed.")
}
return try {
val previousById = _uiState.value.scrapers.associateBy { it.id }
val (repo, scrapers) = fetchRepositoryData(
manifestUrl = manifestUrl,
previousScrapers = previousById,
)
_uiState.update { state ->
state.copy(
repositories = state.repositories + repo,
scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl } + scrapers,
)
}
persist()
pushToServer()
AddPluginRepositoryResult.Success(repo)
} catch (error: Throwable) {
AddPluginRepositoryResult.Error(error.message ?: "Unable to install plugin repository")
}
}
fun removeRepository(manifestUrl: String) {
initialize()
_uiState.update { state ->
state.copy(
repositories = state.repositories.filterNot { it.manifestUrl == manifestUrl },
scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl },
)
}
persist()
pushToServer()
}
fun refreshAll() {
initialize()
_uiState.value.repositories.forEach { repo ->
refreshRepository(repo.manifestUrl)
}
}
fun refreshRepository(manifestUrl: String, pushAfterRefresh: Boolean = false) {
initialize()
val existingJob = activeRefreshJobs[manifestUrl]
if (existingJob?.isActive == true) return
markRefreshing(manifestUrl)
var refreshJob: Job? = null
refreshJob = scope.launch {
try {
val result = runCatching {
val previous = _uiState.value.scrapers.associateBy { it.id }
fetchRepositoryData(manifestUrl, previous)
}
_uiState.update { state ->
result.fold(
onSuccess = { (repo, scrapers) ->
val updatedRepos = state.repositories.map { existing ->
if (existing.manifestUrl == manifestUrl) repo else existing
}
state.copy(
repositories = updatedRepos,
scrapers = state.scrapers.filterNot { it.repositoryUrl == manifestUrl } + scrapers,
)
},
onFailure = { error ->
state.copy(
repositories = state.repositories.map { existing ->
if (existing.manifestUrl == manifestUrl) {
existing.copy(
isRefreshing = false,
errorMessage = error.message ?: "Unable to refresh repository",
)
} else {
existing
}
},
)
},
)
}
persist()
if (pushAfterRefresh) {
pushToServer()
}
} finally {
if (activeRefreshJobs[manifestUrl] === refreshJob) {
activeRefreshJobs.remove(manifestUrl)
}
}
}
activeRefreshJobs[manifestUrl] = refreshJob
}
fun toggleScraper(scraperId: String, enabled: Boolean) {
initialize()
_uiState.update { state ->
state.copy(
scrapers = state.scrapers.map { scraper ->
if (scraper.id == scraperId) {
scraper.copy(enabled = if (scraper.manifestEnabled) enabled else false)
} else {
scraper
}
},
)
}
persist()
}
fun setPluginsEnabled(enabled: Boolean) {
initialize()
_uiState.update { it.copy(pluginsEnabled = enabled) }
persist()
}
fun getEnabledScrapersForType(type: String): List<PluginScraper> {
initialize()
if (!_uiState.value.pluginsEnabled) return emptyList()
return _uiState.value.scrapers.filter { scraper ->
scraper.enabled && scraper.supportsType(type)
}
}
suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> {
initialize()
val scraper = _uiState.value.scrapers.find { it.id == scraperId }
?: return Result.failure(IllegalArgumentException("Scraper not found"))
val mediaType = if (scraper.supportsType("movie")) "movie" else "tv"
val season = if (mediaType == "tv") 1 else null
val episode = if (mediaType == "tv") 1 else null
return executeScraper(
scraper = scraper,
tmdbId = "603",
mediaType = mediaType,
season = season,
episode = episode,
)
}
suspend fun executeScraper(
scraper: PluginScraper,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
): Result<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,
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 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
}
}

View file

@ -0,0 +1,796 @@
package com.nuvio.app.features.plugins
import co.touchlab.kermit.Logger
import com.dokar.quickjs.binding.define
import com.dokar.quickjs.binding.function
import com.dokar.quickjs.quickJs
import com.fleeksoft.ksoup.Ksoup
import com.fleeksoft.ksoup.nodes.Document
import com.fleeksoft.ksoup.nodes.Element
import com.fleeksoft.ksoup.select.Elements
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlin.random.Random
private const val PLUGIN_TIMEOUT_MS = 60_000L
private const val MAX_FETCH_BODY_CHARS = 256 * 1024
private const val MAX_FETCH_HEADER_VALUE_CHARS = 8 * 1024
private const val FETCH_TRUNCATION_SUFFIX = "\n...[truncated]"
internal object PluginRuntime {
private val log = Logger.withTag("PluginRuntime")
private val json = Json {
ignoreUnknownKeys = true
}
private val containsRegex = Regex(""":contains\([\"']([^\"']+)[\"']\)""")
suspend fun executePlugin(
code: String,
tmdbId: String,
mediaType: String,
season: Int?,
episode: Int?,
scraperId: String,
scraperSettings: Map<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("__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;
};
var cheerio = {
load: function(html) {
var docId = __cheerio_load(html);
var $ = function(selector, context) {
if (selector && selector._elementIds) return selector;
if (context && context._elementIds && context._elementIds.length > 0) {
var allIds = [];
for (var i = 0; i < context._elementIds.length; i++) {
var childIdsJson = __cheerio_find(docId, context._elementIds[i], selector);
var childIds = JSON.parse(childIdsJson);
allIds = allIds.concat(childIds);
}
return createCheerioWrapperFromIds(docId, allIds);
}
return createCheerioWrapper(docId, selector);
};
$.html = function(el) {
if (el && el._elementIds && el._elementIds.length > 0) {
return __cheerio_html(docId, el._elementIds[0]);
}
return __cheerio_html(docId, '');
};
return $;
}
};
function createCheerioWrapper(docId, selector) {
var elementIds;
if (typeof selector === 'string') {
var idsJson = __cheerio_select(docId, selector);
elementIds = JSON.parse(idsJson);
} else {
elementIds = [];
}
return createCheerioWrapperFromIds(docId, elementIds);
}
function createCheerioWrapperFromIds(docId, ids) {
var wrapper = {
_docId: docId,
_elementIds: ids,
length: ids.length,
each: function(callback) {
for (var i = 0; i < ids.length; i++) {
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
callback.call(elWrapper, i, elWrapper);
}
return wrapper;
},
find: function(sel) {
var allIds = [];
for (var i = 0; i < ids.length; i++) {
var childIdsJson = __cheerio_find(docId, ids[i], sel);
var childIds = JSON.parse(childIdsJson);
allIds = allIds.concat(childIds);
}
return createCheerioWrapperFromIds(docId, allIds);
},
text: function() {
if (ids.length === 0) return '';
return __cheerio_text(docId, ids.join(','));
},
html: function() {
if (ids.length === 0) return '';
return __cheerio_inner_html(docId, ids[0]);
},
attr: function(name) {
if (ids.length === 0) return undefined;
var val = __cheerio_attr(docId, ids[0], name);
return val === '__UNDEFINED__' ? undefined : val;
},
first: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[0]] : []); },
last: function() { return createCheerioWrapperFromIds(docId, ids.length > 0 ? [ids[ids.length - 1]] : []); },
next: function() {
var nextIds = [];
for (var i = 0; i < ids.length; i++) {
var nextId = __cheerio_next(docId, ids[i]);
if (nextId && nextId !== '__NONE__') nextIds.push(nextId);
}
return createCheerioWrapperFromIds(docId, nextIds);
},
prev: function() {
var prevIds = [];
for (var i = 0; i < ids.length; i++) {
var prevId = __cheerio_prev(docId, ids[i]);
if (prevId && prevId !== '__NONE__') prevIds.push(prevId);
}
return createCheerioWrapperFromIds(docId, prevIds);
},
eq: function(index) {
if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]);
return createCheerioWrapperFromIds(docId, []);
},
get: function(index) {
if (typeof index === 'number') {
if (index >= 0 && index < ids.length) return createCheerioWrapperFromIds(docId, [ids[index]]);
return undefined;
}
return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); });
},
map: function(callback) {
var results = [];
for (var i = 0; i < ids.length; i++) {
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
var result = callback.call(elWrapper, i, elWrapper);
if (result !== undefined && result !== null) results.push(result);
}
return {
length: results.length,
get: function(index) { return typeof index === 'number' ? results[index] : results; },
toArray: function() { return results; }
};
},
filter: function(selectorOrCallback) {
if (typeof selectorOrCallback === 'function') {
var filteredIds = [];
for (var i = 0; i < ids.length; i++) {
var elWrapper = createCheerioWrapperFromIds(docId, [ids[i]]);
var result = selectorOrCallback.call(elWrapper, i, elWrapper);
if (result) filteredIds.push(ids[i]);
}
return createCheerioWrapperFromIds(docId, filteredIds);
}
return wrapper;
},
children: function(sel) { return this.find(sel || '*'); },
parent: function() { return createCheerioWrapperFromIds(docId, []); },
toArray: function() { return ids.map(function(id) { return createCheerioWrapperFromIds(docId, [id]); }); }
};
return wrapper;
}
var require = function(moduleName) {
if (moduleName === 'cheerio' || moduleName === 'cheerio-without-node-native' || moduleName === 'react-native-cheerio') {
return cheerio;
}
throw new Error("Module '" + moduleName + "' is not available");
};
if (!Array.prototype.flat) {
Array.prototype.flat = function(depth) {
depth = depth === undefined ? 1 : Math.floor(depth);
if (depth < 1) return Array.prototype.slice.call(this);
return (function flatten(arr, d) {
return d > 0
? arr.reduce(function(acc, val) { return acc.concat(Array.isArray(val) ? flatten(val, d - 1) : val); }, [])
: arr.slice();
})(this, depth);
};
}
if (!Array.prototype.flatMap) {
Array.prototype.flatMap = function(callback, thisArg) { return this.map(callback, thisArg).flat(); };
}
if (!Object.entries) {
Object.entries = function(obj) {
var result = [];
for (var key in obj) {
if (obj.hasOwnProperty(key)) result.push([key, obj[key]]);
}
return result;
};
}
if (!Object.fromEntries) {
Object.fromEntries = function(entries) {
var result = {};
for (var i = 0; i < entries.length; i++) {
result[entries[i][0]] = entries[i][1];
}
return result;
};
}
if (!String.prototype.replaceAll) {
String.prototype.replaceAll = function(search, replace) {
if (search instanceof RegExp) {
if (!search.global) throw new TypeError('replaceAll must be called with a global RegExp');
return this.replace(search, replace);
}
return this.split(search).join(replace);
};
}
""".trimIndent()
}
}

View file

@ -0,0 +1,366 @@
package com.nuvio.app.features.plugins
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioIconActionButton
import com.nuvio.app.core.ui.NuvioInfoBadge
import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioSurfaceCard
import kotlinx.coroutines.launch
@Composable
fun PluginsSettingsPageContent(
modifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) {
PluginRepository.initialize()
}
val uiState by PluginRepository.uiState.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
var repositoryUrl by rememberSaveable { mutableStateOf("") }
var message by rememberSaveable { mutableStateOf<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 sortedScrapers = remember(uiState.scrapers) {
uiState.scrapers.sortedBy { it.name.lowercase() }
}
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
NuvioSectionLabel("OVERVIEW")
NuvioSurfaceCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(text = "${sortedRepos.size} repos")
NuvioInfoBadge(text = "${sortedScrapers.size} scrapers")
NuvioInfoBadge(
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "Enable plugin scrapers globally",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Switch(
checked = uiState.pluginsEnabled,
onCheckedChange = { PluginRepository.setPluginsEnabled(it) },
)
}
}
NuvioSectionLabel("ADD REPOSITORY")
NuvioSurfaceCard {
NuvioInputField(
value = repositoryUrl,
onValueChange = {
repositoryUrl = it
message = null
},
placeholder = "Plugin manifest URL",
)
Spacer(modifier = Modifier.height(16.dp))
NuvioPrimaryButton(
text = if (isAdding) "Installing..." else "Install Plugin Repository",
enabled = repositoryUrl.isNotBlank() && !isAdding,
onClick = {
val requested = repositoryUrl.trim()
if (requested.isBlank()) {
message = "Enter a plugin repository URL."
return@NuvioPrimaryButton
}
isAdding = true
message = null
coroutineScope.launch {
when (val result = PluginRepository.addRepository(requested)) {
is AddPluginRepositoryResult.Success -> {
repositoryUrl = ""
message = "Installed ${result.repository.name}."
}
is AddPluginRepositoryResult.Error -> {
message = result.message
}
}
isAdding = false
}
},
)
message?.let { text ->
Spacer(modifier = Modifier.height(12.dp))
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
NuvioSectionLabel("INSTALLED REPOSITORIES")
if (sortedRepos.isEmpty()) {
NuvioSurfaceCard {
Text(
text = "No plugin repositories installed yet.",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add a repository URL to download JS scrapers and use them in stream discovery.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
sortedRepos.forEach { repo ->
NuvioSurfaceCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = repo.name,
style = MaterialTheme.typography.headlineLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
repo.version?.let { version ->
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Version $version",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = repo.manifestUrl,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
NuvioIconActionButton(
icon = Icons.Rounded.Refresh,
contentDescription = "Refresh plugin repository",
tint = MaterialTheme.colorScheme.primary,
onClick = { PluginRepository.refreshRepository(repo.manifestUrl, pushAfterRefresh = true) },
)
NuvioIconActionButton(
icon = Icons.Rounded.Delete,
contentDescription = "Delete plugin repository",
tint = MaterialTheme.colorScheme.error,
onClick = { PluginRepository.removeRepository(repo.manifestUrl) },
)
}
}
Spacer(modifier = Modifier.height(14.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(text = "${repo.scraperCount} scrapers")
if (repo.isRefreshing) {
NuvioInfoBadge(text = "Refreshing")
}
}
repo.errorMessage?.let { errorMessage ->
Spacer(modifier = Modifier.height(10.dp))
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
NuvioSectionLabel("SCRAPERS")
if (sortedScrapers.isEmpty()) {
NuvioSurfaceCard {
Text(
text = "No scrapers available yet.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
sortedScrapers.forEach { scraper ->
val scraperResults = testResults[scraper.id]
val isTestingThisScraper = testingScraperId == scraper.id
NuvioSurfaceCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Row(
modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Rounded.Extension,
contentDescription = null,
tint = if (scraper.enabled) Color(0xFF68B76A) else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = scraper.name,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = scraper.description.ifBlank { "No description" },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Switch(
checked = scraper.enabled,
onCheckedChange = { PluginRepository.toggleScraper(scraper.id, it) },
enabled = scraper.manifestEnabled,
)
}
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(text = scraper.supportedTypes.joinToString(" | "))
NuvioInfoBadge(text = "v${scraper.version}")
}
Spacer(modifier = Modifier.height(12.dp))
NuvioPrimaryButton(
text = if (isTestingThisScraper) "Testing..." else "Test Scraper",
enabled = !isTestingThisScraper,
onClick = {
testingScraperId = scraper.id
coroutineScope.launch {
PluginRepository.testScraper(scraper.id)
.onSuccess { results ->
testResults[scraper.id] = results
}
.onFailure { error ->
testResults[scraper.id] = listOf(
PluginRuntimeResult(
title = "Error",
name = error.message ?: "Scraper test failed",
url = "about:error",
),
)
}
testingScraperId = null
}
},
)
if (!scraperResults.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(12.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Test results (${scraperResults.size})",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
scraperResults.take(8).forEach { result ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
Icon(
imageVector = Icons.Rounded.Bolt,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = result.title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = result.url,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
}
}

View file

@ -9,6 +9,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.library.LibraryRepository
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.search.SearchHistoryRepository
import com.nuvio.app.features.settings.ThemeSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
@ -122,6 +123,7 @@ object ProfileRepository {
LibraryRepository.onProfileChanged(profileIndex)
WatchProgressRepository.onProfileChanged(profileIndex)
AddonRepository.onProfileChanged(profileIndex)
PluginRepository.onProfileChanged(profileIndex)
ThemeSettingsRepository.onProfileChanged()
PlayerSettingsRepository.onProfileChanged()
HomeCatalogSettingsRepository.onProfileChanged()

View file

@ -3,11 +3,13 @@ package com.nuvio.app.features.settings
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Extension
import androidx.compose.material.icons.rounded.Hub
import androidx.compose.material.icons.rounded.Tune
internal fun LazyListScope.contentDiscoveryContent(
isTablet: Boolean,
onAddonsClick: () -> Unit,
onPluginsClick: () -> Unit,
onHomescreenClick: () -> Unit,
) {
item {
@ -23,6 +25,13 @@ internal fun LazyListScope.contentDiscoveryContent(
isTablet = isTablet,
onClick = onAddonsClick,
)
SettingsNavigationRow(
title = "Plugins",
description = "Install JavaScript scraper repositories and test providers internally.",
icon = Icons.Rounded.Hub,
isTablet = isTablet,
onClick = onPluginsClick,
)
}
}
}

View file

@ -0,0 +1,14 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxWidth
import com.nuvio.app.features.plugins.PluginsSettingsPageContent
internal fun LazyListScope.pluginsSettingsContent() {
item {
PluginsSettingsPageContent(
modifier = Modifier.fillMaxWidth(),
)
}
}

View file

@ -13,6 +13,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -102,6 +103,29 @@ fun AddonsSettingsScreen(
}
}
@Composable
fun PluginsSettingsScreen(
onBack: () -> Unit,
) {
LaunchedEffect(Unit) {
PluginRepository.initialize()
}
NuvioScreen(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
stickyHeader {
NuvioScreenHeader(
title = "Plugins",
onBack = onBack,
)
}
pluginsSettingsContent()
}
}
@Composable
fun AccountSettingsScreen(
onBack: () -> Unit,

View file

@ -53,6 +53,11 @@ internal enum class SettingsPage(
category = SettingsCategory.General,
parentPage = ContentDiscovery,
),
Plugins(
title = "Plugins",
category = SettingsCategory.General,
parentPage = ContentDiscovery,
),
Homescreen(
title = "Homescreen",
category = SettingsCategory.General,

View file

@ -57,6 +57,7 @@ fun SettingsScreen(
onHomescreenClick: () -> Unit = {},
onContinueWatchingClick: () -> Unit = {},
onAddonsClick: () -> Unit = {},
onPluginsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
) {
BoxWithConstraints(
@ -165,6 +166,7 @@ fun SettingsScreen(
onHomescreenClick = onHomescreenClick,
onContinueWatchingClick = onContinueWatchingClick,
onAddonsClick = onAddonsClick,
onPluginsClick = onPluginsClick,
onAccountClick = onAccountClick,
)
}
@ -199,6 +201,7 @@ private fun MobileSettingsScreen(
onHomescreenClick: () -> Unit = {},
onContinueWatchingClick: () -> Unit = {},
onAddonsClick: () -> Unit = {},
onPluginsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
) {
NuvioScreen {
@ -253,9 +256,11 @@ private fun MobileSettingsScreen(
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = false,
onAddonsClick = onAddonsClick,
onPluginsClick = onPluginsClick,
onHomescreenClick = onHomescreenClick,
)
SettingsPage.Addons -> addonsSettingsContent()
SettingsPage.Plugins -> pluginsSettingsContent()
SettingsPage.Homescreen -> homescreenSettingsContent(
isTablet = false,
heroEnabled = homescreenHeroEnabled,
@ -430,9 +435,11 @@ private fun TabletSettingsScreen(
SettingsPage.ContentDiscovery -> contentDiscoveryContent(
isTablet = true,
onAddonsClick = { openInlinePage(SettingsPage.Addons) },
onPluginsClick = { openInlinePage(SettingsPage.Plugins) },
onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) },
)
SettingsPage.Addons -> addonsSettingsContent()
SettingsPage.Plugins -> pluginsSettingsContent()
SettingsPage.Homescreen -> homescreenSettingsContent(
isTablet = true,
heroEnabled = homescreenHeroEnabled,

View file

@ -4,6 +4,9 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -25,16 +28,16 @@ object StreamsRepository {
private var activeRequestKey: String? = null
fun load(type: String, videoId: String) {
load(type = type, videoId = videoId, forceRefresh = false)
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null) {
load(type = type, videoId = videoId, season = season, episode = episode, forceRefresh = false)
}
fun reload(type: String, videoId: String) {
load(type = type, videoId = videoId, forceRefresh = true)
fun reload(type: String, videoId: String, season: Int? = null, episode: Int? = null) {
load(type = type, videoId = videoId, season = season, episode = episode, forceRefresh = true)
}
private fun load(type: String, videoId: String, forceRefresh: Boolean) {
val requestKey = "$type::$videoId"
private fun load(type: String, videoId: String, season: Int?, episode: Int?, forceRefresh: Boolean) {
val requestKey = "$type::$videoId::$season::$episode"
val currentState = _uiState.value
if (
!forceRefresh &&
@ -67,7 +70,10 @@ object StreamsRepository {
}
val installedAddons = AddonRepository.uiState.value.addons
if (installedAddons.isEmpty()) {
PluginRepository.initialize()
val pluginScrapers = PluginRepository.getEnabledScrapersForType(type)
if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) {
_uiState.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
@ -88,7 +94,7 @@ object StreamsRepository {
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
if (streamAddons.isEmpty()) {
if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) {
_uiState.value = StreamsUiState(
isAnyLoading = false,
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
@ -104,16 +110,23 @@ object StreamsRepository {
streams = emptyList(),
isLoading = true,
)
} + pluginScrapers.map { scraper ->
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = emptyList(),
isLoading = true,
)
}
_uiState.value = StreamsUiState(
groups = initialGroups,
activeAddonIds = streamAddons.map { it.id }.toSet(),
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
isAnyLoading = true,
emptyStateReason = null,
)
activeJob = scope.launch {
val jobs = streamAddons.map { manifest ->
val addonJobs = streamAddons.map { manifest ->
async {
val encodedId = videoId.encodeForPath()
val baseUrl = manifest.transportUrl
@ -153,6 +166,38 @@ object StreamsRepository {
}
}
val pluginJobs = pluginScrapers.map { scraper ->
async {
PluginRepository.executeScraper(
scraper = scraper,
tmdbId = videoId.toPluginTmdbId(),
mediaType = type,
season = season,
episode = episode,
).fold(
onSuccess = { results ->
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = results.map { it.toStreamItem(scraper) },
isLoading = false,
)
},
onFailure = { error ->
AddonStreamGroup(
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
streams = emptyList(),
isLoading = false,
error = error.message,
)
},
)
}
}
val jobs = addonJobs + pluginJobs
// Collect results as they arrive and update state incrementally
jobs.forEach { deferred ->
val result = deferred.await()
@ -197,3 +242,27 @@ private fun List<AddonStreamGroup>.toEmptyStateReason(anyLoading: Boolean): Stre
StreamsEmptyStateReason.NoStreamsFound
}
}
private fun String.toPluginTmdbId(): String {
return when {
startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this }
startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this }
else -> this
}
}
private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem {
val subtitleParts = listOfNotNull(
quality?.takeIf { it.isNotBlank() },
size?.takeIf { it.isNotBlank() },
language?.takeIf { it.isNotBlank() },
)
return StreamItem(
name = name ?: title,
description = subtitleParts.joinToString("").ifBlank { null },
url = url,
infoHash = infoHash,
addonName = scraper.name,
addonId = "plugin:${scraper.id}",
)
}

View file

@ -131,7 +131,12 @@ fun StreamsScreen(
}
LaunchedEffect(type, videoId) {
StreamsRepository.load(type, videoId)
StreamsRepository.load(
type = type,
videoId = videoId,
season = seasonNumber,
episode = episodeNumber,
)
}
LaunchedEffect(uiState.groups, storedProgress?.providerAddonId, preferredFilterApplied) {
@ -210,7 +215,16 @@ fun StreamsScreen(
color = MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
shape = CircleShape,
)
.clickable(onClick = { StreamsRepository.reload(type, videoId) }),
.clickable(
onClick = {
StreamsRepository.reload(
type = type,
videoId = videoId,
season = seasonNumber,
episode = episodeNumber,
)
},
),
contentAlignment = Alignment.Center,
) {
Icon(

View file

@ -6,6 +6,7 @@ internal actual object PlatformLocalAccountDataCleaner {
private val plainKeys = listOf("profile_payload")
private val profileIndexedPrefixes = listOf(
"installed_manifest_urls_",
"plugins_state_",
"library_payload_",
"watched_payload_",
"watch_progress_payload_",

View file

@ -7,10 +7,13 @@ import io.ktor.client.request.accept
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.post
import io.ktor.client.request.request
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.isSuccess
import platform.Foundation.NSUserDefaults
@ -123,3 +126,32 @@ actual suspend fun httpPostJsonWithHeaders(
}
payload
}
actual suspend fun httpRequestRaw(
method: String,
url: String,
headers: Map<String, String>,
body: String,
): RawHttpResponse =
addonHttpClient
.request {
url(url)
this.method = HttpMethod.parse(method.uppercase())
headers.forEach { (key, value) ->
header(key, value)
}
if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) {
setBody(body)
}
}
.let { response ->
RawHttpResponse(
status = response.status.value,
statusText = response.status.description,
url = response.call.request.url.toString(),
body = response.bodyAsText(),
headers = response.headers.entries().associate { (name, values) ->
name.lowercase() to values.joinToString(",")
},
)
}

View file

@ -0,0 +1,23 @@
package com.nuvio.app.features.plugins
import platform.Foundation.NSUserDefaults
import platform.Foundation.timeIntervalSince1970
internal actual object PluginStorage {
private const val pluginsStateKey = "plugins_state"
actual fun loadState(profileId: Int): String? =
NSUserDefaults.standardUserDefaults.stringForKey("${pluginsStateKey}_$profileId")
actual fun saveState(profileId: Int, payload: String) {
NSUserDefaults.standardUserDefaults.setObject(
payload,
forKey = "${pluginsStateKey}_$profileId",
)
}
}
internal actual fun currentPluginPlatform(): String = "ios"
internal actual fun currentEpochMillis(): Long =
(platform.Foundation.NSDate().timeIntervalSince1970 * 1000.0).toLong()

View file

@ -20,6 +20,8 @@ ktor = "3.4.1"
material3 = "1.10.0-alpha05"
androidx-media3 = "1.10.0-rc01"
supabase = "3.4.1"
quickjsKt = "1.0.1"
ksoup = "0.2.6"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@ -62,6 +64,8 @@ androidx-media3-extractor = { module = "androidx.media3:media3-extractor", versi
supabase-postgrest = { module = "io.github.jan-tennert.supabase:postgrest-kt", version.ref = "supabase" }
supabase-auth = { module = "io.github.jan-tennert.supabase:auth-kt", version.ref = "supabase" }
supabase-functions = { module = "io.github.jan-tennert.supabase:functions-kt", version.ref = "supabase" }
quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt" }
ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }