mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-18 05:15:41 +00:00
fix: split plugin source cache from metadata (NUVIO-MOBILE-1)
This commit is contained in:
parent
4f2c2b90d0
commit
3b7d6f4b9e
9 changed files with 526 additions and 121 deletions
|
|
@ -6,11 +6,14 @@ import android.content.SharedPreferences
|
|||
internal object PluginStorage {
|
||||
private const val preferencesName = "nuvio_plugins"
|
||||
private const val pluginsStateKey = "plugins_state"
|
||||
private const val scraperCodeDirectoryName = "nuvio_plugin_scrapers"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
private var scraperCodeStore: PluginScraperCodeFileStore? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
scraperCodeStore = PluginScraperCodeFileStore(context.filesDir.resolve(scraperCodeDirectoryName))
|
||||
}
|
||||
|
||||
fun loadState(profileId: Int): String? =
|
||||
|
|
@ -23,6 +26,19 @@ internal object PluginStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
fun hasScraperCode(profileId: Int, scraperId: String): Boolean =
|
||||
scraperCodeStore?.contains(profileId, scraperId) == true
|
||||
|
||||
fun loadScraperCode(profileId: Int, scraperId: String): String? =
|
||||
scraperCodeStore?.load(profileId, scraperId)
|
||||
|
||||
fun saveScraperCode(
|
||||
profileId: Int,
|
||||
scraperId: String,
|
||||
code: String,
|
||||
overwrite: Boolean,
|
||||
): Boolean = scraperCodeStore?.save(profileId, scraperId, code, overwrite) == true
|
||||
|
||||
fun loadScraperSettings(scraperId: String): String? =
|
||||
preferences?.getString("settings_${scraperId}", null)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
package com.nuvio.app.features.plugins
|
||||
|
||||
import kotlinx.atomicfu.locks.SynchronizedObject
|
||||
import kotlinx.atomicfu.locks.synchronized
|
||||
import java.io.File
|
||||
|
||||
internal class PluginScraperCodeFileStore(
|
||||
private val root: File,
|
||||
) {
|
||||
private val lock = SynchronizedObject()
|
||||
|
||||
fun contains(profileId: Int, scraperId: String): Boolean =
|
||||
scraperCodeFile(profileId, scraperId).isFile
|
||||
|
||||
fun load(profileId: Int, scraperId: String): String? = synchronized(lock) {
|
||||
val file = scraperCodeFile(profileId, scraperId).takeIf(File::isFile)
|
||||
?: return@synchronized null
|
||||
runCatching { file.readText() }.getOrNull()
|
||||
}
|
||||
|
||||
fun save(
|
||||
profileId: Int,
|
||||
scraperId: String,
|
||||
code: String,
|
||||
overwrite: Boolean,
|
||||
): Boolean {
|
||||
val target = scraperCodeFile(profileId, scraperId)
|
||||
if (!overwrite && target.isFile) return true
|
||||
return synchronized(lock) {
|
||||
if (!overwrite && target.isFile) return@synchronized true
|
||||
val directory = target.parentFile ?: return@synchronized false
|
||||
if (!directory.exists() && !directory.mkdirs()) return@synchronized false
|
||||
|
||||
val temporary = runCatching {
|
||||
File.createTempFile("scraper-", ".tmp", directory)
|
||||
}.getOrNull() ?: return@synchronized false
|
||||
val backup = directory.resolve("${target.name}.backup")
|
||||
|
||||
try {
|
||||
temporary.bufferedWriter().use { writer -> writer.write(code) }
|
||||
if (!overwrite && target.isFile) return@synchronized true
|
||||
if (backup.exists() && !backup.delete()) return@synchronized false
|
||||
val hadTarget = target.exists()
|
||||
if (hadTarget && !target.renameTo(backup)) return@synchronized false
|
||||
if (!temporary.renameTo(target)) {
|
||||
if (hadTarget) backup.renameTo(target)
|
||||
return@synchronized false
|
||||
}
|
||||
if (backup.exists()) backup.delete()
|
||||
true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
} finally {
|
||||
if (temporary.exists()) temporary.delete()
|
||||
if (backup.exists() && !target.exists()) backup.renameTo(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scraperCodeFile(profileId: Int, scraperId: String): File {
|
||||
val fileName = "${pluginDigestHex("SHA256", scraperId)}.js"
|
||||
return root.resolve(profileId.toString()).resolve(fileName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
package com.nuvio.app.features.plugins
|
||||
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PluginScraperCodeFileStoreTest {
|
||||
@Test
|
||||
fun scraperCodeRoundTripsWithoutRewritingUnlessRequested() {
|
||||
val root = Files.createTempDirectory("nuvio-plugin-code").toFile()
|
||||
try {
|
||||
val store = PluginScraperCodeFileStore(root)
|
||||
val scraperId = "https://plugins.example/manifest.json:scraper"
|
||||
val original = "x".repeat(4 * 1024 * 1024)
|
||||
val refreshed = "updated scraper"
|
||||
|
||||
assertFalse(store.contains(1, scraperId))
|
||||
assertTrue(store.save(1, scraperId, original, overwrite = false))
|
||||
assertEquals(original, store.load(1, scraperId))
|
||||
|
||||
assertTrue(store.save(1, scraperId, refreshed, overwrite = false))
|
||||
assertEquals(original, store.load(1, scraperId))
|
||||
|
||||
assertTrue(store.save(1, scraperId, refreshed, overwrite = true))
|
||||
assertEquals(refreshed, store.load(1, scraperId))
|
||||
assertFalse(store.contains(2, scraperId))
|
||||
} finally {
|
||||
root.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -50,5 +50,6 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
.clear()
|
||||
.apply()
|
||||
}
|
||||
context.filesDir.resolve("nuvio_plugin_scrapers").deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,9 +134,75 @@ internal data class StoredPluginScraper(
|
|||
val logo: String? = null,
|
||||
val contentLanguage: List<String> = emptyList(),
|
||||
val formats: List<String>? = null,
|
||||
val code: String,
|
||||
val code: String? = null,
|
||||
)
|
||||
|
||||
internal data class RestoredPluginScraper(
|
||||
val scraper: PluginScraper,
|
||||
val requiresMigration: Boolean,
|
||||
)
|
||||
|
||||
internal fun PluginsUiState.toStoredPluginsState(): StoredPluginsState =
|
||||
StoredPluginsState(
|
||||
pluginsEnabled = pluginsEnabled,
|
||||
groupStreamsByRepository = groupStreamsByRepository,
|
||||
repositories = repositories.map { repository ->
|
||||
StoredPluginRepository(
|
||||
manifestUrl = repository.manifestUrl,
|
||||
name = repository.name,
|
||||
description = repository.description,
|
||||
version = repository.version,
|
||||
scraperCount = repository.scraperCount,
|
||||
lastUpdated = repository.lastUpdated,
|
||||
)
|
||||
},
|
||||
scrapers = scrapers.map(PluginScraper::toStoredPluginScraper),
|
||||
)
|
||||
|
||||
internal fun PluginScraper.toStoredPluginScraper(): StoredPluginScraper =
|
||||
StoredPluginScraper(
|
||||
id = id,
|
||||
repositoryUrl = repositoryUrl,
|
||||
name = name,
|
||||
description = description,
|
||||
version = version,
|
||||
filename = filename,
|
||||
supportedTypes = supportedTypes,
|
||||
enabled = enabled,
|
||||
manifestEnabled = manifestEnabled,
|
||||
hasSettings = hasSettings,
|
||||
logo = logo,
|
||||
contentLanguage = contentLanguage,
|
||||
formats = formats,
|
||||
code = null,
|
||||
)
|
||||
|
||||
internal fun StoredPluginScraper.restorePluginScraper(
|
||||
loadCachedCode: (String) -> String?,
|
||||
): RestoredPluginScraper? {
|
||||
val cachedCode = loadCachedCode(id)
|
||||
val resolvedCode = cachedCode ?: code ?: return null
|
||||
return RestoredPluginScraper(
|
||||
scraper = PluginScraper(
|
||||
id = id,
|
||||
repositoryUrl = repositoryUrl,
|
||||
name = name,
|
||||
description = description,
|
||||
version = version,
|
||||
filename = filename,
|
||||
supportedTypes = supportedTypes,
|
||||
enabled = enabled,
|
||||
manifestEnabled = manifestEnabled,
|
||||
hasSettings = hasSettings,
|
||||
logo = logo,
|
||||
contentLanguage = contentLanguage,
|
||||
formats = formats,
|
||||
code = resolvedCode,
|
||||
),
|
||||
requiresMigration = code != null,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun normalizePluginType(value: String): String =
|
||||
when (value.lowercase()) {
|
||||
"series", "show", "other" -> "tv"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
package com.nuvio.app.features.plugins
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PluginPersistenceTest {
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
@Test
|
||||
fun metadataStateDoesNotSerializeScraperSourceCode() {
|
||||
val sourceCode = "module.exports = " + "x".repeat(4 * 1024 * 1024)
|
||||
val stored = PluginsUiState(
|
||||
scrapers = listOf(pluginScraper(sourceCode)),
|
||||
).toStoredPluginsState()
|
||||
|
||||
val encoded = json.encodeToString(stored)
|
||||
|
||||
assertNull(stored.scrapers.single().code)
|
||||
assertTrue(encoded.length < 2_048)
|
||||
assertFalse(sourceCode in encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cachedScraperCodeRestoresOfflineWithoutMigration() {
|
||||
val sourceCode = "offline scraper source"
|
||||
val stored = pluginScraper(sourceCode).toStoredPluginScraper()
|
||||
|
||||
val restored = stored.restorePluginScraper { scraperId ->
|
||||
if (scraperId == stored.id) sourceCode else null
|
||||
}
|
||||
|
||||
assertEquals(sourceCode, restored?.scraper?.code)
|
||||
assertFalse(restored?.requiresMigration ?: true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun legacyEmbeddedScraperCodeIsPreservedForMigration() {
|
||||
val sourceCode = "legacy scraper source"
|
||||
val stored = pluginScraper(sourceCode)
|
||||
.toStoredPluginScraper()
|
||||
.copy(code = sourceCode)
|
||||
|
||||
val restored = stored.restorePluginScraper { null }
|
||||
|
||||
assertEquals(sourceCode, restored?.scraper?.code)
|
||||
assertTrue(restored?.requiresMigration == true)
|
||||
}
|
||||
|
||||
private fun pluginScraper(code: String): PluginScraper = PluginScraper(
|
||||
id = "https://plugins.example/manifest.json:scraper",
|
||||
repositoryUrl = "https://plugins.example/manifest.json",
|
||||
name = "Scraper",
|
||||
description = "",
|
||||
version = "1.0.0",
|
||||
filename = "scraper.js",
|
||||
supportedTypes = listOf("movie", "tv"),
|
||||
enabled = true,
|
||||
manifestEnabled = true,
|
||||
code = code,
|
||||
)
|
||||
}
|
||||
|
|
@ -9,6 +9,9 @@ import com.nuvio.app.features.plugins.runtime.PluginRuntime
|
|||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.query.Order
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.atomicfu.locks.SynchronizedObject
|
||||
import kotlinx.atomicfu.locks.synchronized
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
|
@ -52,6 +55,18 @@ private data class PluginPushItem(
|
|||
@SerialName("sort_order") val sortOrder: Int = 0,
|
||||
)
|
||||
|
||||
private data class PluginPersistenceSnapshot(
|
||||
val profileId: Int,
|
||||
val generation: Long,
|
||||
val revision: Long,
|
||||
val state: PluginsUiState,
|
||||
)
|
||||
|
||||
private data class LoadedPluginState(
|
||||
val state: PluginsUiState,
|
||||
val requiresMigration: Boolean,
|
||||
)
|
||||
|
||||
actual object PluginRepository {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("PluginRepository")
|
||||
|
|
@ -64,6 +79,10 @@ actual object PluginRepository {
|
|||
private var pulledFromServer = false
|
||||
private var currentProfileId = 1
|
||||
private val activeRefreshJobs = mutableMapOf<String, Job>()
|
||||
private val persistenceGeneration = atomic(0L)
|
||||
private val persistenceRevision = atomic(0L)
|
||||
private val persistenceLock = SynchronizedObject()
|
||||
private val persistedRevisionByProfile = mutableMapOf<Int, Long>()
|
||||
|
||||
actual fun initialize() {
|
||||
val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId)
|
||||
|
|
@ -89,6 +108,7 @@ actual object PluginRepository {
|
|||
|
||||
actual fun clearLocalState() {
|
||||
cancelActiveRefreshes()
|
||||
persistenceGeneration.incrementAndGet()
|
||||
currentProfileId = 1
|
||||
initialized = false
|
||||
pulledFromServer = false
|
||||
|
|
@ -358,59 +378,71 @@ actual object PluginRepository {
|
|||
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")
|
||||
): Pair<PluginRepositoryItem, List<PluginScraper>> {
|
||||
val storageProfileId = currentProfileId
|
||||
return 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
|
||||
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 cached = PluginStorage.saveScraperCode(
|
||||
profileId = storageProfileId,
|
||||
scraperId = scraperId,
|
||||
code = code,
|
||||
overwrite = true,
|
||||
)
|
||||
if (!cached) {
|
||||
log.w { "Failed to cache plugin scraper $scraperId" }
|
||||
}
|
||||
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,
|
||||
hasSettings = info.hasSettings,
|
||||
logo = info.logo,
|
||||
contentLanguage = info.contentLanguage ?: emptyList(),
|
||||
formats = info.formats ?: info.supportedFormats,
|
||||
code = code,
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
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,
|
||||
hasSettings = info.hasSettings,
|
||||
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
|
||||
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 {
|
||||
|
|
@ -460,39 +492,49 @@ actual object PluginRepository {
|
|||
}
|
||||
|
||||
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,
|
||||
hasSettings = scraper.hasSettings,
|
||||
logo = scraper.logo,
|
||||
contentLanguage = scraper.contentLanguage,
|
||||
formats = scraper.formats,
|
||||
code = scraper.code,
|
||||
) },
|
||||
val snapshot = PluginPersistenceSnapshot(
|
||||
profileId = currentProfileId,
|
||||
generation = persistenceGeneration.value,
|
||||
revision = persistenceRevision.incrementAndGet(),
|
||||
state = _uiState.value,
|
||||
)
|
||||
PluginStorage.saveState(currentProfileId, json.encodeToString(payload))
|
||||
val requiresCodeWrite = snapshot.state.scrapers.any { scraper ->
|
||||
!PluginStorage.hasScraperCode(snapshot.profileId, scraper.id)
|
||||
}
|
||||
if (requiresCodeWrite) {
|
||||
scope.launch { persist(snapshot) }
|
||||
} else {
|
||||
persist(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist(snapshot: PluginPersistenceSnapshot) {
|
||||
if (snapshot.generation != persistenceGeneration.value) return
|
||||
var cached = true
|
||||
snapshot.state.scrapers.forEach { scraper ->
|
||||
val scraperCached = PluginStorage.saveScraperCode(
|
||||
profileId = snapshot.profileId,
|
||||
scraperId = scraper.id,
|
||||
code = scraper.code,
|
||||
overwrite = false,
|
||||
)
|
||||
cached = scraperCached && cached
|
||||
}
|
||||
if (!cached || snapshot.generation != persistenceGeneration.value) {
|
||||
if (!cached) {
|
||||
log.w { "Failed to persist plugin scraper cache for profile ${snapshot.profileId}" }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
synchronized(persistenceLock) {
|
||||
if (snapshot.generation != persistenceGeneration.value) return@synchronized
|
||||
val persistedRevision = persistedRevisionByProfile[snapshot.profileId] ?: Long.MIN_VALUE
|
||||
if (snapshot.revision < persistedRevision) return@synchronized
|
||||
val payload = snapshot.state.toStoredPluginsState()
|
||||
PluginStorage.saveState(snapshot.profileId, json.encodeToString(payload))
|
||||
persistedRevisionByProfile[snapshot.profileId] = snapshot.revision
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStoredState(profileId: Int): StoredPluginsState? {
|
||||
|
|
@ -517,49 +559,45 @@ actual object PluginRepository {
|
|||
}
|
||||
|
||||
currentProfileId = profileId
|
||||
_uiState.value = loadStateAsUiState(profileId)
|
||||
val loadedState = loadStateAsUiState(profileId)
|
||||
_uiState.value = loadedState.state
|
||||
initialized = true
|
||||
if (loadedState.requiresMigration) persist()
|
||||
}
|
||||
|
||||
private fun loadStateAsUiState(profileId: Int): PluginsUiState {
|
||||
private fun loadStateAsUiState(profileId: Int): LoadedPluginState {
|
||||
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,
|
||||
hasSettings = it.hasSettings,
|
||||
logo = it.logo,
|
||||
contentLanguage = it.contentLanguage,
|
||||
formats = it.formats,
|
||||
code = it.code,
|
||||
)
|
||||
}
|
||||
?: emptyList(),
|
||||
var requiresMigration = false
|
||||
val scrapers = stored?.scrapers
|
||||
?.mapNotNull { storedScraper ->
|
||||
storedScraper.restorePluginScraper { scraperId ->
|
||||
PluginStorage.loadScraperCode(profileId, scraperId)
|
||||
}?.also { restored ->
|
||||
requiresMigration = requiresMigration || restored.requiresMigration
|
||||
}?.scraper
|
||||
}
|
||||
?: emptyList()
|
||||
return LoadedPluginState(
|
||||
state = 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 = scrapers,
|
||||
),
|
||||
requiresMigration = requiresMigration,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,30 @@
|
|||
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
|
||||
package com.nuvio.app.features.plugins
|
||||
|
||||
import kotlinx.atomicfu.locks.SynchronizedObject
|
||||
import kotlinx.atomicfu.locks.synchronized
|
||||
import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import kotlinx.cinterop.addressOf
|
||||
import kotlinx.cinterop.convert
|
||||
import kotlinx.cinterop.usePinned
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSHomeDirectory
|
||||
import platform.Foundation.NSUserDefaults
|
||||
import platform.Foundation.timeIntervalSince1970
|
||||
import platform.posix.SEEK_END
|
||||
import platform.posix.fclose
|
||||
import platform.posix.fopen
|
||||
import platform.posix.fread
|
||||
import platform.posix.fseek
|
||||
import platform.posix.ftell
|
||||
import platform.posix.fwrite
|
||||
import platform.posix.rewind
|
||||
|
||||
internal object PluginStorage {
|
||||
private const val pluginsStateKey = "plugins_state"
|
||||
private const val scraperCodeDirectoryName = "nuvio_plugin_scrapers"
|
||||
private val scraperCodeLock = SynchronizedObject()
|
||||
|
||||
fun loadState(profileId: Int): String? =
|
||||
NSUserDefaults.standardUserDefaults.stringForKey("${pluginsStateKey}_$profileId")
|
||||
|
|
@ -16,6 +36,53 @@ internal object PluginStorage {
|
|||
)
|
||||
}
|
||||
|
||||
fun hasScraperCode(profileId: Int, scraperId: String): Boolean =
|
||||
NSFileManager.defaultManager.fileExistsAtPath(scraperCodePath(profileId, scraperId))
|
||||
|
||||
fun loadScraperCode(profileId: Int, scraperId: String): String? = synchronized(scraperCodeLock) {
|
||||
readUtf8File(scraperCodePath(profileId, scraperId))
|
||||
}
|
||||
|
||||
fun saveScraperCode(
|
||||
profileId: Int,
|
||||
scraperId: String,
|
||||
code: String,
|
||||
overwrite: Boolean,
|
||||
): Boolean {
|
||||
val manager = NSFileManager.defaultManager
|
||||
val target = scraperCodePath(profileId, scraperId)
|
||||
if (!overwrite && manager.fileExistsAtPath(target)) return true
|
||||
return synchronized(scraperCodeLock) {
|
||||
val directory = scraperCodeDirectory(profileId)
|
||||
if (!manager.createDirectoryAtPath(directory, true, null, null)) return@synchronized false
|
||||
if (!overwrite && manager.fileExistsAtPath(target)) return@synchronized true
|
||||
|
||||
val temporary = "$target.tmp"
|
||||
val backup = "$target.backup"
|
||||
if (!writeUtf8File(temporary, code)) return@synchronized false
|
||||
|
||||
try {
|
||||
if (!overwrite && manager.fileExistsAtPath(target)) return@synchronized true
|
||||
if (manager.fileExistsAtPath(backup) && !manager.removeItemAtPath(backup, null)) {
|
||||
return@synchronized false
|
||||
}
|
||||
val hadTarget = manager.fileExistsAtPath(target)
|
||||
if (hadTarget && !manager.moveItemAtPath(target, backup, null)) return@synchronized false
|
||||
if (!manager.moveItemAtPath(temporary, target, null)) {
|
||||
if (hadTarget) manager.moveItemAtPath(backup, target, null)
|
||||
return@synchronized false
|
||||
}
|
||||
if (manager.fileExistsAtPath(backup)) manager.removeItemAtPath(backup, null)
|
||||
true
|
||||
} finally {
|
||||
if (manager.fileExistsAtPath(temporary)) manager.removeItemAtPath(temporary, null)
|
||||
if (manager.fileExistsAtPath(backup) && !manager.fileExistsAtPath(target)) {
|
||||
manager.moveItemAtPath(backup, target, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadScraperSettings(scraperId: String): String? =
|
||||
NSUserDefaults.standardUserDefaults.stringForKey("settings_${scraperId}")
|
||||
|
||||
|
|
@ -25,6 +92,51 @@ internal object PluginStorage {
|
|||
forKey = "settings_${scraperId}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun scraperCodeDirectory(profileId: Int): String =
|
||||
"${NSHomeDirectory()}/Library/Application Support/$scraperCodeDirectoryName/$profileId"
|
||||
|
||||
private fun scraperCodePath(profileId: Int, scraperId: String): String =
|
||||
"${scraperCodeDirectory(profileId)}/${pluginDigestHex("SHA256", scraperId)}.js"
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun readUtf8File(path: String): String? {
|
||||
val file = fopen(path, "rb") ?: return null
|
||||
return try {
|
||||
if (fseek(file, 0L, SEEK_END) != 0) return null
|
||||
val size = ftell(file)
|
||||
if (size < 0L || size > Int.MAX_VALUE.toLong()) return null
|
||||
rewind(file)
|
||||
val bytes = ByteArray(size.toInt())
|
||||
if (bytes.isNotEmpty()) {
|
||||
val read = bytes.usePinned { pinned ->
|
||||
fread(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
|
||||
}
|
||||
if (read.toLong() != bytes.size.toLong()) return null
|
||||
}
|
||||
bytes.decodeToString()
|
||||
} finally {
|
||||
fclose(file)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
private fun writeUtf8File(path: String, value: String): Boolean {
|
||||
val bytes = value.encodeToByteArray()
|
||||
val file = fopen(path, "wb") ?: return false
|
||||
return try {
|
||||
if (bytes.isEmpty()) {
|
||||
true
|
||||
} else {
|
||||
val written = bytes.usePinned { pinned ->
|
||||
fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
|
||||
}
|
||||
written.toLong() == bytes.size.toLong()
|
||||
}
|
||||
} finally {
|
||||
fclose(file)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun currentPluginPlatform(): String = "ios"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
|
||||
|
||||
package com.nuvio.app.core.storage
|
||||
|
||||
import platform.Foundation.NSUserDefaults
|
||||
import platform.Foundation.NSFileManager
|
||||
import platform.Foundation.NSHomeDirectory
|
||||
import com.nuvio.app.features.profiles.MAX_PROFILES
|
||||
|
||||
internal actual object PlatformLocalAccountDataCleaner {
|
||||
|
|
@ -90,5 +94,10 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
defaults.removeObjectForKey(keyString)
|
||||
}
|
||||
}
|
||||
|
||||
val scraperCodePath = "${NSHomeDirectory()}/Library/Application Support/nuvio_plugin_scrapers"
|
||||
if (NSFileManager.defaultManager.fileExistsAtPath(scraperCodePath)) {
|
||||
NSFileManager.defaultManager.removeItemAtPath(scraperCodePath, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue